@jokerized/decksmith 0.3.1 → 0.3.2
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 +112 -1
- package/dist/cli.js +2814 -730
- package/dist/deck-player-element.js +1 -0
- package/dist/deck-player.js +1 -0
- package/dist/deck-runtime.js +27 -2
- package/dist/embed.html +243 -0
- package/dist/index.js +2888 -854
- package/dist/mcp.js +2865 -818
- package/dist/types/deck/player-element.d.ts +1 -0
- package/dist/types/deck/player.d.ts +37 -0
- package/dist/types/deck/protocol.d.ts +80 -0
- package/dist/types/deck/runtime.d.ts +36 -0
- package/dist/types/emit/archetypes/claim-figure.d.ts +0 -7
- package/dist/types/emit/kit.d.ts +33 -0
- package/dist/types/images/providers.d.ts +14 -5
- package/dist/types/index.d.ts +13 -0
- package/dist/types/mcp/tools.d.ts +20 -0
- package/dist/types/net/fetch.d.ts +81 -0
- package/dist/types/pack/media.d.ts +12 -0
- package/dist/types/plan/prompt.d.ts +2 -1
- package/dist/types/server/pipeline.d.ts +51 -8
- package/dist/types/server/upload.d.ts +29 -5
- package/dist/types/source/assets.d.ts +77 -4
- package/dist/types/source/harvest.d.ts +250 -0
- package/dist/types/source/readability.d.ts +120 -0
- package/dist/types/source/transcode.d.ts +83 -0
- package/dist/types/types.d.ts +21 -0
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir as homedir3 } from "node:os";
|
|
|
5
5
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
-
import { z as
|
|
8
|
+
import { z as z6 } from "zod";
|
|
9
9
|
|
|
10
10
|
// src/version.ts
|
|
11
11
|
import { createRequire } from "node:module";
|
|
@@ -13,17 +13,232 @@ var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
|
13
13
|
|
|
14
14
|
// src/mcp/tools.ts
|
|
15
15
|
import { randomBytes } from "node:crypto";
|
|
16
|
-
import { mkdir as
|
|
16
|
+
import { mkdir as mkdir11, mkdtemp as mkdtemp3, readFile as readFile13, rm as rm8 } from "node:fs/promises";
|
|
17
17
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
18
|
-
import { isAbsolute, join as
|
|
19
|
-
import {
|
|
18
|
+
import { basename as basename4, isAbsolute as isAbsolute2, join as join13, resolve as resolve6, sep as sep2 } from "node:path";
|
|
19
|
+
import { zipSync as zipSync2 } from "fflate";
|
|
20
|
+
import { z as z5 } from "zod";
|
|
20
21
|
|
|
21
22
|
// src/index.ts
|
|
22
|
-
import { cp, mkdir as
|
|
23
|
+
import { cp, mkdir as mkdir9, readdir as readdir2, readFile as readFile11, stat as stat2, writeFile as writeFile11 } from "node:fs/promises";
|
|
23
24
|
import { createRequire as createRequire2 } from "node:module";
|
|
24
|
-
import { dirname as dirname3, join as
|
|
25
|
+
import { dirname as dirname3, join as join11, resolve as resolve4 } from "node:path";
|
|
25
26
|
import { fileURLToPath } from "node:url";
|
|
26
27
|
|
|
28
|
+
// src/pack/media.ts
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { readFile } from "node:fs/promises";
|
|
31
|
+
import { extname } from "node:path";
|
|
32
|
+
var PLAYERS = [
|
|
33
|
+
"youtube.com",
|
|
34
|
+
"youtube-nocookie.com",
|
|
35
|
+
"youtu.be",
|
|
36
|
+
"vimeo.com",
|
|
37
|
+
"dailymotion.com",
|
|
38
|
+
"dai.ly",
|
|
39
|
+
"twitch.tv",
|
|
40
|
+
"loom.com",
|
|
41
|
+
"wistia.com",
|
|
42
|
+
"wistia.net",
|
|
43
|
+
"streamable.com",
|
|
44
|
+
"bilibili.com",
|
|
45
|
+
"soundcloud.com",
|
|
46
|
+
"tiktok.com"
|
|
47
|
+
];
|
|
48
|
+
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
49
|
+
".png",
|
|
50
|
+
".jpg",
|
|
51
|
+
".jpeg",
|
|
52
|
+
".gif",
|
|
53
|
+
".webp",
|
|
54
|
+
".avif",
|
|
55
|
+
".svg",
|
|
56
|
+
".mp4",
|
|
57
|
+
".webm",
|
|
58
|
+
".mov",
|
|
59
|
+
".m4v",
|
|
60
|
+
".mp3",
|
|
61
|
+
".m4a",
|
|
62
|
+
".wav",
|
|
63
|
+
".ogg",
|
|
64
|
+
".opus",
|
|
65
|
+
".pdf"
|
|
66
|
+
]);
|
|
67
|
+
function isEmbed(url) {
|
|
68
|
+
const host2 = hostOf(url);
|
|
69
|
+
return host2 !== null && PLAYERS.some((p) => host2 === p || host2.endsWith(`.${p}`));
|
|
70
|
+
}
|
|
71
|
+
function segments(u) {
|
|
72
|
+
return u.pathname.split("/").filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
function token(raw2) {
|
|
75
|
+
return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
|
|
76
|
+
}
|
|
77
|
+
var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
|
|
78
|
+
var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
|
|
79
|
+
var YOUTUBE = {
|
|
80
|
+
origin: YOUTUBE_EMBED,
|
|
81
|
+
embed: (u) => {
|
|
82
|
+
const seg = segments(u);
|
|
83
|
+
const path2 = seg[0];
|
|
84
|
+
return youtube(
|
|
85
|
+
token(
|
|
86
|
+
path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var YOUTU_BE = {
|
|
92
|
+
origin: YOUTUBE_EMBED,
|
|
93
|
+
embed: (u) => youtube(token(segments(u)[0]))
|
|
94
|
+
};
|
|
95
|
+
var VIMEO_EMBED = "https://player.vimeo.com";
|
|
96
|
+
var VIMEO = {
|
|
97
|
+
origin: VIMEO_EMBED,
|
|
98
|
+
embed: (u) => {
|
|
99
|
+
const seg = segments(u);
|
|
100
|
+
const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
|
|
101
|
+
if (head === void 0 || !/^\d+$/.test(head)) return void 0;
|
|
102
|
+
const hash = token(next ?? u.searchParams.get("h"));
|
|
103
|
+
return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var DAILYMOTION_EMBED = "https://www.dailymotion.com";
|
|
107
|
+
var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
|
|
108
|
+
var DAILYMOTION = {
|
|
109
|
+
origin: DAILYMOTION_EMBED,
|
|
110
|
+
embed: (u) => {
|
|
111
|
+
const seg = segments(u);
|
|
112
|
+
return dailymotion(
|
|
113
|
+
token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
var DAI_LY = {
|
|
118
|
+
origin: DAILYMOTION_EMBED,
|
|
119
|
+
embed: (u) => dailymotion(token(segments(u)[0]))
|
|
120
|
+
};
|
|
121
|
+
var LOOM = {
|
|
122
|
+
origin: "https://www.loom.com",
|
|
123
|
+
embed: (u) => {
|
|
124
|
+
const seg = segments(u);
|
|
125
|
+
const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
|
|
126
|
+
return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var EMBEDS = {
|
|
130
|
+
"youtube.com": YOUTUBE,
|
|
131
|
+
"youtube-nocookie.com": YOUTUBE,
|
|
132
|
+
"youtu.be": YOUTU_BE,
|
|
133
|
+
"vimeo.com": VIMEO,
|
|
134
|
+
"dailymotion.com": DAILYMOTION,
|
|
135
|
+
"dai.ly": DAI_LY,
|
|
136
|
+
"loom.com": LOOM
|
|
137
|
+
};
|
|
138
|
+
var EMBED_ORIGINS = [
|
|
139
|
+
...new Set(Object.values(EMBEDS).map((e) => e.origin))
|
|
140
|
+
];
|
|
141
|
+
function embedUrl(url) {
|
|
142
|
+
const host2 = hostOf(url);
|
|
143
|
+
if (host2 === null) return void 0;
|
|
144
|
+
let u;
|
|
145
|
+
try {
|
|
146
|
+
u = new URL(url);
|
|
147
|
+
} catch {
|
|
148
|
+
return void 0;
|
|
149
|
+
}
|
|
150
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
|
|
151
|
+
const rule = Object.entries(EMBEDS).find(([h]) => host2 === h || host2.endsWith(`.${h}`))?.[1];
|
|
152
|
+
return rule?.embed(u);
|
|
153
|
+
}
|
|
154
|
+
function policyFor(url, prefer) {
|
|
155
|
+
if (isEmbed(url)) return "embed";
|
|
156
|
+
const host2 = hostOf(url);
|
|
157
|
+
if (host2 === null || /^data:/i.test(url)) return "bake";
|
|
158
|
+
if (prefer === "link") return "link";
|
|
159
|
+
return FILE_EXT.has(extOf(url)) ? "bake" : "link";
|
|
160
|
+
}
|
|
161
|
+
async function planMedia(assets, fetcher = fetchAsset) {
|
|
162
|
+
const plan = {
|
|
163
|
+
media: [],
|
|
164
|
+
files: {},
|
|
165
|
+
bakedCount: 0,
|
|
166
|
+
bakedBytes: 0,
|
|
167
|
+
demoted: [],
|
|
168
|
+
promoted: []
|
|
169
|
+
};
|
|
170
|
+
for (const asset of assets) {
|
|
171
|
+
const policy = policyFor(asset.url, asset.prefer);
|
|
172
|
+
if (policy !== "bake") {
|
|
173
|
+
if (asset.prefer === "bake") plan.demoted.push(asset.id);
|
|
174
|
+
plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
178
|
+
const got = await fetcher(asset.url);
|
|
179
|
+
const mime = clean(got.mime) ?? asset.mime;
|
|
180
|
+
if (mime === "text/html") {
|
|
181
|
+
plan.demoted.push(asset.id);
|
|
182
|
+
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
186
|
+
plan.files[path2] = got.bytes;
|
|
187
|
+
plan.bakedCount += 1;
|
|
188
|
+
plan.bakedBytes += got.bytes.length;
|
|
189
|
+
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
190
|
+
}
|
|
191
|
+
return plan;
|
|
192
|
+
}
|
|
193
|
+
function bakedName(id2, url, mime) {
|
|
194
|
+
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
195
|
+
const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
|
|
196
|
+
return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
|
|
197
|
+
}
|
|
198
|
+
function extOf(url) {
|
|
199
|
+
const path2 = hostOf(url) === null ? url : new URL(url).pathname;
|
|
200
|
+
const ext = extname(path2).toLowerCase();
|
|
201
|
+
return FILE_EXT.has(ext) ? ext : "";
|
|
202
|
+
}
|
|
203
|
+
var MIME_EXT = {
|
|
204
|
+
"image/png": ".png",
|
|
205
|
+
"image/jpeg": ".jpg",
|
|
206
|
+
"image/gif": ".gif",
|
|
207
|
+
"image/webp": ".webp",
|
|
208
|
+
"image/avif": ".avif",
|
|
209
|
+
"image/svg+xml": ".svg",
|
|
210
|
+
"video/mp4": ".mp4",
|
|
211
|
+
"video/webm": ".webm",
|
|
212
|
+
"audio/mpeg": ".mp3",
|
|
213
|
+
"application/pdf": ".pdf"
|
|
214
|
+
};
|
|
215
|
+
function extFor(mime) {
|
|
216
|
+
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
217
|
+
}
|
|
218
|
+
function hostOf(url) {
|
|
219
|
+
try {
|
|
220
|
+
const u = new URL(url);
|
|
221
|
+
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
222
|
+
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function clean(mime) {
|
|
228
|
+
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
229
|
+
}
|
|
230
|
+
async function fetchAsset(url) {
|
|
231
|
+
if (/^(https?|data):/i.test(url)) {
|
|
232
|
+
const res = await fetch(url);
|
|
233
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
234
|
+
return {
|
|
235
|
+
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
236
|
+
mime: res.headers.get("content-type") ?? void 0
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { bytes: new Uint8Array(await readFile(url)) };
|
|
240
|
+
}
|
|
241
|
+
|
|
27
242
|
// src/types.ts
|
|
28
243
|
import { z } from "zod";
|
|
29
244
|
var refSchema = z.object({
|
|
@@ -32,9 +247,34 @@ var refSchema = z.object({
|
|
|
32
247
|
});
|
|
33
248
|
var figureSchema = z.object({
|
|
34
249
|
id: z.string(),
|
|
250
|
+
/**
|
|
251
|
+
* WHAT THE ASSET IS. A CLIP IS A FIGURE.
|
|
252
|
+
*
|
|
253
|
+
* A harvested page hands back stills and video in one pass, and the obvious
|
|
254
|
+
* shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
|
|
255
|
+
* costs every layer that already knows what a figure is: the inventory
|
|
256
|
+
* `renderSource` prints, `assertRefsResolve`, the archetypes that take a
|
|
257
|
+
* `figureId`, and the pack. All of them would need a second word for "the
|
|
258
|
+
* thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
|
|
259
|
+
* that a fifth entry would put into every stored plan's schema. A clip is a
|
|
260
|
+
* rectangle with intrinsic pixels and a caption that a beat points at, which
|
|
261
|
+
* is what a figure is; what differs is one branch at emit.
|
|
262
|
+
*
|
|
263
|
+
* DEFAULTED rather than required, and that is the whole point of the field
|
|
264
|
+
* being an enum with a default: every `source.json` written before clips
|
|
265
|
+
* existed parses unchanged and comes back an `image`, which is what it has
|
|
266
|
+
* always been.
|
|
267
|
+
*/
|
|
268
|
+
kind: z.enum(["image", "clip"]).default("image"),
|
|
35
269
|
/** Path relative to the deck's asset directory. */
|
|
36
270
|
src: z.string(),
|
|
37
271
|
caption: z.string(),
|
|
272
|
+
/**
|
|
273
|
+
* The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
|
|
274
|
+
* dimensions, not the poster's. Every fit, crop and leader-line fraction
|
|
275
|
+
* downstream is expressed against this box, so a clip whose poster was
|
|
276
|
+
* letterboxed to another shape would put every annotation in the wrong place.
|
|
277
|
+
*/
|
|
38
278
|
width: z.int().positive(),
|
|
39
279
|
height: z.int().positive(),
|
|
40
280
|
/**
|
|
@@ -52,7 +292,26 @@ var figureSchema = z.object({
|
|
|
52
292
|
*/
|
|
53
293
|
sectionId: z.string().optional(),
|
|
54
294
|
/** The sentence or paragraph that refers to it, verbatim from the document. */
|
|
55
|
-
mention: z.string().optional()
|
|
295
|
+
mention: z.string().optional(),
|
|
296
|
+
// THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
|
|
297
|
+
// is defaulted: an image carries none of them, and a source written before
|
|
298
|
+
// clips existed parses into exactly the object it always did.
|
|
299
|
+
/**
|
|
300
|
+
* The local still that represents the clip — what the deck shows before
|
|
301
|
+
* anyone presses play, and what stands in wherever the video cannot run at
|
|
302
|
+
* all. A clip whose video could not be downloaded is this still and nothing
|
|
303
|
+
* else, so it is the picture the beat is really planned around.
|
|
304
|
+
*/
|
|
305
|
+
poster: z.string().optional(),
|
|
306
|
+
/** How long the clip runs. Seconds, as measured off the file, never guessed. */
|
|
307
|
+
seconds: z.number().positive().optional(),
|
|
308
|
+
/**
|
|
309
|
+
* The page the video lives ON, when the video itself is not a file we can
|
|
310
|
+
* fetch — a player page, an embed, anything whose terms or DRM make the bytes
|
|
311
|
+
* unavailable. Then `poster` is all the deck can show, and this is where a
|
|
312
|
+
* viewer goes to watch the thing. Absent for a clip we hold the file for.
|
|
313
|
+
*/
|
|
314
|
+
href: z.string().optional()
|
|
56
315
|
});
|
|
57
316
|
var equationSchema = z.object({
|
|
58
317
|
id: z.string(),
|
|
@@ -910,8 +1169,8 @@ function clock(seconds) {
|
|
|
910
1169
|
}
|
|
911
1170
|
|
|
912
1171
|
// src/source/fonts.ts
|
|
913
|
-
import { createHash } from "node:crypto";
|
|
914
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1172
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1173
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
915
1174
|
import { join } from "node:path";
|
|
916
1175
|
function familyFor(lang) {
|
|
917
1176
|
const tag = lang.toLowerCase();
|
|
@@ -925,11 +1184,11 @@ async function bundleFont(lang, glyphs, dir) {
|
|
|
925
1184
|
const family = familyFor(lang);
|
|
926
1185
|
if (!family) return null;
|
|
927
1186
|
const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
|
|
928
|
-
const stamp = `/* decksmith ${
|
|
1187
|
+
const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
|
|
929
1188
|
${text2}`).digest("hex").slice(0, 16)} */`;
|
|
930
1189
|
await mkdir(dir, { recursive: true });
|
|
931
1190
|
const cssPath = join(dir, "fonts.css");
|
|
932
|
-
const cached = await
|
|
1191
|
+
const cached = await readFile2(cssPath, "utf8").catch(() => "");
|
|
933
1192
|
if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
|
|
934
1193
|
const res = await fetch(
|
|
935
1194
|
`https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
|
|
@@ -1811,11 +2070,11 @@ function unwidow(text2, width, size, weight = 700, face = "latin") {
|
|
|
1811
2070
|
function wrapTokens(tokens, size, width, weight, face) {
|
|
1812
2071
|
const lines = [];
|
|
1813
2072
|
let line2 = "";
|
|
1814
|
-
for (const
|
|
1815
|
-
const candidate = line2 ? `${line2} ${
|
|
2073
|
+
for (const token2 of tokens) {
|
|
2074
|
+
const candidate = line2 ? `${line2} ${token2}` : token2;
|
|
1816
2075
|
if (line2 && textWidth(candidate, size, weight, 0, false, face) > width) {
|
|
1817
2076
|
lines.push(line2);
|
|
1818
|
-
line2 =
|
|
2077
|
+
line2 = token2;
|
|
1819
2078
|
continue;
|
|
1820
2079
|
}
|
|
1821
2080
|
line2 = candidate;
|
|
@@ -2165,6 +2424,11 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2165
2424
|
`annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
|
|
2166
2425
|
);
|
|
2167
2426
|
}
|
|
2427
|
+
if (fig.kind === "clip") {
|
|
2428
|
+
throw new Error(
|
|
2429
|
+
`annotated-figure ${beat.id}: figure "${fig.id}" is a clip, and notes can only be pinned to a still \u2014 use claim-figure, which plays it, or point this beat at the clip's poster as a figure of its own`
|
|
2430
|
+
);
|
|
2431
|
+
}
|
|
2168
2432
|
const crop = p.crop;
|
|
2169
2433
|
const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
|
|
2170
2434
|
const clamp3 = (v) => Math.min(1, Math.max(0, v));
|
|
@@ -2183,7 +2447,7 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2183
2447
|
}
|
|
2184
2448
|
const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format), face);
|
|
2185
2449
|
const stageH = plan.height;
|
|
2186
|
-
const
|
|
2450
|
+
const plate2 = {
|
|
2187
2451
|
x: plan.img.x - PLATE,
|
|
2188
2452
|
y: plan.img.y - PLATE,
|
|
2189
2453
|
w: plan.img.w + 2 * PLATE,
|
|
@@ -2337,7 +2601,7 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2337
2601
|
// Per-scene geometry: the overlay is only correct because the image box is
|
|
2338
2602
|
// stated rather than negotiated with the layout engine.
|
|
2339
2603
|
`#${sid}-stage{width:${STAGE_W}px;height:${n(stageH)}px;margin-top:${STAGE_GAP}px}`,
|
|
2340
|
-
`#${sid}-plate{left:${n(
|
|
2604
|
+
`#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
|
|
2341
2605
|
// `overflow:visible` because a dot sitting on the figure's own edge puts its
|
|
2342
2606
|
// halo outside the viewBox, and a clipped halo reads as a rendering fault.
|
|
2343
2607
|
`#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
|
|
@@ -2793,6 +3057,31 @@ var CLAIM_LH = 1.5;
|
|
|
2793
3057
|
var CLAIM_RULE = 6 + 32;
|
|
2794
3058
|
var BESIDE_COL = 560;
|
|
2795
3059
|
var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
|
|
3060
|
+
function plate(fig, sid, beatId, start) {
|
|
3061
|
+
const img = (src) => ({
|
|
3062
|
+
html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
|
|
3063
|
+
el: "img"
|
|
3064
|
+
});
|
|
3065
|
+
if (fig.kind !== "clip") return img(fig.src);
|
|
3066
|
+
if (fig.href !== void 0) {
|
|
3067
|
+
if (fig.poster === void 0) {
|
|
3068
|
+
throw new Error(
|
|
3069
|
+
`claim-figure ${beatId}: figure "${fig.id}" is a clip we hold no file for and no still of \u2014 re-ingest it so its poster is measured, or point the beat at a figure this deck has`
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
return img(fig.poster);
|
|
3073
|
+
}
|
|
3074
|
+
if (start === void 0) {
|
|
3075
|
+
throw new Error(
|
|
3076
|
+
`claim-figure ${beatId}: figure "${fig.id}" is a clip, and nobody said when this scene starts \u2014 the video is seeked on the deck's absolute clock, so \`EmitContext.start\` has to be passed`
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
|
|
3080
|
+
return {
|
|
3081
|
+
html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
|
|
3082
|
+
el: "video"
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
2796
3085
|
var claimFigure = (beat, ctx) => {
|
|
2797
3086
|
const { sid, theme } = ctx;
|
|
2798
3087
|
const p = beat.params;
|
|
@@ -2832,7 +3121,8 @@ var claimFigure = (beat, ctx) => {
|
|
|
2832
3121
|
);
|
|
2833
3122
|
}
|
|
2834
3123
|
const claim = `<div class="claim" id="${sid}-c">${words(p.claim)}</div>`;
|
|
2835
|
-
const
|
|
3124
|
+
const held = plate(fig, sid, beat.id, ctx.start);
|
|
3125
|
+
const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
|
|
2836
3126
|
const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
|
|
2837
3127
|
const body = tall ? `<div class="cf-stack">${claim}
|
|
2838
3128
|
<div>${figure}
|
|
@@ -2900,12 +3190,22 @@ ${body}`,
|
|
|
2900
3190
|
// Height-capped rather than width-driven: a square figure in the beside
|
|
2901
3191
|
// layout would otherwise be ~970px tall and run off the canvas. The cap is
|
|
2902
3192
|
// the measured remainder, not a constant — see `figMax`.
|
|
2903
|
-
|
|
3193
|
+
//
|
|
3194
|
+
// NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
|
|
3195
|
+
// A rule listing `img, video` would move the bytes of every deck we have
|
|
3196
|
+
// ever built to describe an element almost none of them contain, and a
|
|
3197
|
+
// rule naming only `img` over a clip is a cap that silently does not
|
|
3198
|
+
// apply — the video would render at its natural 1920x1080 and run off the
|
|
3199
|
+
// canvas, which is invariant-5 territory that no gate reads.
|
|
3200
|
+
`.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
|
|
2904
3201
|
`.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
|
|
2905
3202
|
// The image, not its wrapper: the wrapper's entrance already writes
|
|
2906
3203
|
// `transform`. 1.2% of the 550px cap is 3.3px a side, which the wrapper's
|
|
2907
|
-
// 16px padding absorbs — the swell can never reach the canvas edge.
|
|
2908
|
-
|
|
3204
|
+
// 16px padding absorbs — the swell can never reach the canvas edge. Same
|
|
3205
|
+
// reason as the cap above for naming the tag: a drift rule aimed at `img`
|
|
3206
|
+
// over a `<video>` is one ambient rule that animates nothing, and the
|
|
3207
|
+
// slide reads as dead rather than as held.
|
|
3208
|
+
ambient(sid, `-f ${held.el}`, DRIFT)
|
|
2909
3209
|
].join("\n")
|
|
2910
3210
|
};
|
|
2911
3211
|
};
|
|
@@ -3025,8 +3325,8 @@ var dataTable = (beat, ctx) => {
|
|
|
3025
3325
|
p.highlight.forEach((h, i) => {
|
|
3026
3326
|
const index = shown.findIndex((row) => row[0] === h.row);
|
|
3027
3327
|
if (index < 0) {
|
|
3028
|
-
const
|
|
3029
|
-
throw new Error(`data-table ${beat.id}: ${
|
|
3328
|
+
const why2 = table.rows.some((row) => row[0] === h.row) ? `no row labelled "${h.row}" is drawn \u2014 table ${table.id} has one, but params.rows left it out` : `no row labelled "${h.row}" in table ${table.id}`;
|
|
3329
|
+
throw new Error(`data-table ${beat.id}: ${why2}`);
|
|
3030
3330
|
}
|
|
3031
3331
|
const at = settled + 0.3 + i * step;
|
|
3032
3332
|
tl.push(
|
|
@@ -3341,14 +3641,14 @@ var MORPH_SECONDS = 1.6;
|
|
|
3341
3641
|
var equationMorph = (beat, ctx) => {
|
|
3342
3642
|
const { sid, theme } = ctx;
|
|
3343
3643
|
const p = beat.params;
|
|
3344
|
-
const
|
|
3644
|
+
const find3 = (id2) => {
|
|
3345
3645
|
const eq = ctx.source.equations.find((e) => e.id === id2);
|
|
3346
3646
|
if (!eq)
|
|
3347
3647
|
throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
|
|
3348
3648
|
return eq;
|
|
3349
3649
|
};
|
|
3350
|
-
const a =
|
|
3351
|
-
const b =
|
|
3650
|
+
const a = find3(p.fromId);
|
|
3651
|
+
const b = find3(p.toId);
|
|
3352
3652
|
const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
|
|
3353
3653
|
if (both.length === 0) {
|
|
3354
3654
|
throw new Error(
|
|
@@ -3558,10 +3858,10 @@ var grid = (beat, ctx) => {
|
|
|
3558
3858
|
h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
|
|
3559
3859
|
};
|
|
3560
3860
|
};
|
|
3561
|
-
const
|
|
3861
|
+
const boxes3 = p.regions.map(boxOf);
|
|
3562
3862
|
const lx = W - gutter + 40;
|
|
3563
3863
|
const outside = p.regions.map((r, i) => ({ r, i })).filter(({ i }) => inGutter[i]).map(({ r, i }) => {
|
|
3564
|
-
const b =
|
|
3864
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3565
3865
|
const half = lines(r) * LABEL * LH / 2;
|
|
3566
3866
|
return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
|
|
3567
3867
|
}).sort((a, b) => a.y - b.y || a.i - b.i);
|
|
@@ -3579,7 +3879,7 @@ var grid = (beat, ctx) => {
|
|
|
3579
3879
|
const parts = {};
|
|
3580
3880
|
p.regions.forEach((r, i) => {
|
|
3581
3881
|
parts[`rgn${i}`] = r.label;
|
|
3582
|
-
const b =
|
|
3882
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3583
3883
|
const tone2 = theme.tones[r.tone];
|
|
3584
3884
|
rects.push(
|
|
3585
3885
|
`<defs><clipPath id="${id(sid, "rgnclip", i)}">${rect({ x: b.x, y: b.y, w: 0, h: b.h }, { id: id(sid, "sweep", i) })}</clipPath></defs>` + roundRect(b, corner, {
|
|
@@ -3687,7 +3987,7 @@ var grid = (beat, ctx) => {
|
|
|
3687
3987
|
tl.push(...dimCells);
|
|
3688
3988
|
p.regions.forEach((_, i) => {
|
|
3689
3989
|
const at = first + i * step;
|
|
3690
|
-
const b =
|
|
3990
|
+
const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
|
|
3691
3991
|
tl.push(
|
|
3692
3992
|
tween(
|
|
3693
3993
|
`#${id(sid, "rgn", i)}`,
|
|
@@ -4087,12 +4387,12 @@ function balance(label, k, face) {
|
|
|
4087
4387
|
if (cur.length > 0) lines.push(cur.join(" "));
|
|
4088
4388
|
return lines;
|
|
4089
4389
|
}
|
|
4090
|
-
function centre(
|
|
4091
|
-
const b =
|
|
4390
|
+
function centre(boxes3, i) {
|
|
4391
|
+
const b = boxes3[i];
|
|
4092
4392
|
return b ? b.x + b.w / 2 : 0;
|
|
4093
4393
|
}
|
|
4094
|
-
function loopLabelWidth(
|
|
4095
|
-
const mid = (centre(
|
|
4394
|
+
function loopLabelWidth(boxes3, from, to, stageW) {
|
|
4395
|
+
const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
|
|
4096
4396
|
return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
|
|
4097
4397
|
}
|
|
4098
4398
|
function widest(lines, face) {
|
|
@@ -4125,11 +4425,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4125
4425
|
let fit = solve(stages, capped, stageW, face);
|
|
4126
4426
|
if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
|
|
4127
4427
|
const size = Math.max(MIN_FONT, Math.floor(fit.size));
|
|
4128
|
-
const
|
|
4129
|
-
const innerW = Math.max(size, (
|
|
4428
|
+
const boxes3 = fit.boxes;
|
|
4429
|
+
const innerW = Math.max(size, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size);
|
|
4130
4430
|
const labelLines = stages.map((s) => wrap(s.label, size, innerW, 600, 0, face));
|
|
4131
4431
|
const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
|
|
4132
|
-
const boxW =
|
|
4432
|
+
const boxW = boxes3[0]?.w ?? width;
|
|
4133
4433
|
const need = Math.max(
|
|
4134
4434
|
MIN_BOX_H,
|
|
4135
4435
|
Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
|
|
@@ -4139,15 +4439,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4139
4439
|
return Math.ceil(2 * PAD_Y2 + label + (note > 0 ? NOTE_TOP + note * NOTE * NOTE_LH : 0));
|
|
4140
4440
|
})
|
|
4141
4441
|
);
|
|
4142
|
-
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(
|
|
4442
|
+
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
|
|
4143
4443
|
const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
|
|
4144
4444
|
const boxH = need;
|
|
4145
4445
|
return {
|
|
4146
4446
|
size,
|
|
4147
|
-
boxes,
|
|
4447
|
+
boxes: boxes3,
|
|
4148
4448
|
boxH,
|
|
4149
4449
|
boxX: M,
|
|
4150
|
-
boxW:
|
|
4450
|
+
boxW: boxes3[0]?.w ?? width,
|
|
4151
4451
|
vertical: false,
|
|
4152
4452
|
innerW,
|
|
4153
4453
|
labelLines,
|
|
@@ -4475,6 +4775,11 @@ var splitCompare = (beat, ctx) => {
|
|
|
4475
4775
|
if (fig.width <= 0 || fig.height <= 0) {
|
|
4476
4776
|
throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
|
|
4477
4777
|
}
|
|
4778
|
+
if (fig.kind === "clip") {
|
|
4779
|
+
throw new Error(
|
|
4780
|
+
`split-compare ${beat.id}: the ${NAME[i]} figure "${fig.id}" is a clip, and a side draws a still <image> \u2014 use claim-figure, which plays it, or point this side at the clip's poster as a figure of its own`
|
|
4781
|
+
);
|
|
4782
|
+
}
|
|
4478
4783
|
return fig;
|
|
4479
4784
|
});
|
|
4480
4785
|
sides.forEach((side, i) => {
|
|
@@ -4773,9 +5078,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
|
4773
5078
|
function stackLayout(p, format, face = "latin") {
|
|
4774
5079
|
const stacked = solve2(p, format, false, face);
|
|
4775
5080
|
if (stacked.fits || !p.layers.some((l) => l.note)) return stacked;
|
|
4776
|
-
const
|
|
4777
|
-
if (
|
|
4778
|
-
return
|
|
5081
|
+
const inline2 = solve2(p, format, true, face);
|
|
5082
|
+
if (inline2.fits) return inline2;
|
|
5083
|
+
return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
|
|
4779
5084
|
}
|
|
4780
5085
|
function labelWeight(i, count) {
|
|
4781
5086
|
return i === count - 1 ? 700 : 600;
|
|
@@ -4784,7 +5089,7 @@ function floorFor(p, format) {
|
|
|
4784
5089
|
if (!p.tilt) return MIN_FONT;
|
|
4785
5090
|
return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
|
|
4786
5091
|
}
|
|
4787
|
-
function solve2(p, format,
|
|
5092
|
+
function solve2(p, format, inline2, face) {
|
|
4788
5093
|
const width = contentW(format);
|
|
4789
5094
|
const boxH = contentH(format);
|
|
4790
5095
|
const floor = floorFor(p, format);
|
|
@@ -4793,15 +5098,15 @@ function solve2(p, format, inline, face) {
|
|
|
4793
5098
|
const riseMax = RISE_MAX[k];
|
|
4794
5099
|
const syMax = SY_MAX[k];
|
|
4795
5100
|
const tMax = T_MAX[k];
|
|
4796
|
-
const noteW = (l) =>
|
|
5101
|
+
const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
|
|
4797
5102
|
const want = Math.max(
|
|
4798
5103
|
...p.layers.map(
|
|
4799
5104
|
(l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
|
|
4800
5105
|
)
|
|
4801
5106
|
);
|
|
4802
|
-
const colCap =
|
|
5107
|
+
const colCap = inline2 ? width * 0.56 : width * 0.5;
|
|
4803
5108
|
const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
|
|
4804
|
-
const wide = !
|
|
5109
|
+
const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
|
|
4805
5110
|
const labelX = width - colW;
|
|
4806
5111
|
const labelRoom = Math.min(
|
|
4807
5112
|
...p.layers.map(
|
|
@@ -4816,14 +5121,14 @@ function solve2(p, format, inline, face) {
|
|
|
4816
5121
|
label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
|
|
4817
5122
|
// Inline notes stay on one line by contract — the schema calls a note "one
|
|
4818
5123
|
// short line" — and wrapping one would put its second line under the label.
|
|
4819
|
-
note: l.note === void 0 ? [] :
|
|
5124
|
+
note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
|
|
4820
5125
|
noteW: nw,
|
|
4821
5126
|
labelMaxW
|
|
4822
5127
|
};
|
|
4823
5128
|
});
|
|
4824
5129
|
const blockH = Math.max(
|
|
4825
5130
|
...lines.map(
|
|
4826
|
-
(l) =>
|
|
5131
|
+
(l) => inline2 ? Math.max(l.label.length * labelSize, l.note.length * floor) * 1.16 : l.label.length * labelSize * 1.16 + (l.note.length > 0 ? 6 + l.note.length * floor * 1.16 : 0)
|
|
4827
5132
|
)
|
|
4828
5133
|
);
|
|
4829
5134
|
const pad = Math.max(EDGE2, blockH / 2);
|
|
@@ -4845,7 +5150,7 @@ function solve2(p, format, inline, face) {
|
|
|
4845
5150
|
fits: room >= blockH + 10 && height <= free && wide,
|
|
4846
5151
|
floor,
|
|
4847
5152
|
wide,
|
|
4848
|
-
inline,
|
|
5153
|
+
inline: inline2,
|
|
4849
5154
|
width,
|
|
4850
5155
|
height,
|
|
4851
5156
|
avail,
|
|
@@ -5249,7 +5554,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
|
|
|
5249
5554
|
format,
|
|
5250
5555
|
laid.slides,
|
|
5251
5556
|
runtimeJs,
|
|
5252
|
-
narrationIsland(opts.narration, laid.spoken)
|
|
5557
|
+
narrationIsland(opts.narration, laid.spoken),
|
|
5558
|
+
videoIsland(laid.embeds)
|
|
5253
5559
|
)
|
|
5254
5560
|
};
|
|
5255
5561
|
}
|
|
@@ -5260,17 +5566,20 @@ function planCut(storyboard, source, format, opts = {}) {
|
|
|
5260
5566
|
const seconds = {};
|
|
5261
5567
|
const undrawable = /* @__PURE__ */ new Set();
|
|
5262
5568
|
floor.forEach((beat, i) => {
|
|
5263
|
-
const
|
|
5569
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5264
5570
|
let scene;
|
|
5265
5571
|
try {
|
|
5266
|
-
({ scene } = stageScene(
|
|
5572
|
+
({ scene } = stageScene(
|
|
5573
|
+
emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
|
|
5574
|
+
speed
|
|
5575
|
+
));
|
|
5267
5576
|
} catch (err) {
|
|
5268
5577
|
if (!opts.onBeatError) throw err;
|
|
5269
5578
|
opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
|
|
5270
5579
|
undrawable.add(beat.id);
|
|
5271
5580
|
return;
|
|
5272
5581
|
}
|
|
5273
|
-
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene,
|
|
5582
|
+
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5274
5583
|
});
|
|
5275
5584
|
if (floor.length > 0 && undrawable.size === floor.length) {
|
|
5276
5585
|
throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
|
|
@@ -5303,31 +5612,29 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5303
5612
|
const scenes = [];
|
|
5304
5613
|
const slides = [];
|
|
5305
5614
|
const spoken = {};
|
|
5615
|
+
const embeds = {};
|
|
5306
5616
|
const entered = enteredParts(beats);
|
|
5617
|
+
let at = 0;
|
|
5307
5618
|
const cuts = beats.map((beat, i) => {
|
|
5308
5619
|
const sid = `s${i + 1}`;
|
|
5309
|
-
const ctx = { source, format, theme, sid };
|
|
5310
|
-
const
|
|
5620
|
+
const ctx = { source, format, theme, sid, start: rnd(at) };
|
|
5621
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5311
5622
|
const { scene } = stageScene(emitScene(beat, ctx), speed);
|
|
5312
|
-
const seconds = beatSeconds(beat.seconds * speed, scene,
|
|
5623
|
+
const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5313
5624
|
const inside = entered[i];
|
|
5314
5625
|
const dive = inside ? { t0: rnd(seconds), dur: rnd(MOVE_SECONDS * speed), fade: rnd(FADE_SECONDS * speed) } : void 0;
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
segments,
|
|
5320
|
-
inside,
|
|
5321
|
-
dive,
|
|
5322
|
-
duration: dive ? seconds + diveTail(dive) : seconds
|
|
5323
|
-
};
|
|
5626
|
+
const duration = dive ? seconds + diveTail(dive) : seconds;
|
|
5627
|
+
const start = at;
|
|
5628
|
+
at += duration;
|
|
5629
|
+
return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
|
|
5324
5630
|
});
|
|
5325
|
-
let start = 0;
|
|
5326
5631
|
let builds = false;
|
|
5327
5632
|
const plugins = /* @__PURE__ */ new Set();
|
|
5328
5633
|
cuts.forEach((cut2, i) => {
|
|
5329
|
-
const { beat, sid, dive, inside, duration } = cut2;
|
|
5634
|
+
const { beat, sid, dive, inside, duration, start } = cut2;
|
|
5330
5635
|
if (cut2.segments?.length) spoken[sid] = cut2.segments;
|
|
5636
|
+
const embed = playerEmbed(beat, source);
|
|
5637
|
+
if (embed) embeds[sid] = embed;
|
|
5331
5638
|
const next = cuts[i + 1];
|
|
5332
5639
|
const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
|
|
5333
5640
|
let scene = cut2.scene;
|
|
@@ -5357,7 +5664,6 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5357
5664
|
notes: beat.narration ?? beat.intent,
|
|
5358
5665
|
holds: scene.holds
|
|
5359
5666
|
});
|
|
5360
|
-
start += duration;
|
|
5361
5667
|
});
|
|
5362
5668
|
return {
|
|
5363
5669
|
family,
|
|
@@ -5369,7 +5675,11 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5369
5675
|
scenes,
|
|
5370
5676
|
slides,
|
|
5371
5677
|
spoken,
|
|
5372
|
-
|
|
5678
|
+
embeds,
|
|
5679
|
+
// The clock after the last scene: the sum the map above finished with, which
|
|
5680
|
+
// is the number the second pass used to arrive at by re-adding the same
|
|
5681
|
+
// durations in the same order.
|
|
5682
|
+
total: at,
|
|
5373
5683
|
cut,
|
|
5374
5684
|
builds,
|
|
5375
5685
|
plugins
|
|
@@ -5420,23 +5730,23 @@ function openSeconds(scene) {
|
|
|
5420
5730
|
const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
|
|
5421
5731
|
return rnd(first ?? 0);
|
|
5422
5732
|
}
|
|
5423
|
-
function beatSeconds(authored, scene,
|
|
5424
|
-
if (!
|
|
5733
|
+
function beatSeconds(authored, scene, segments2) {
|
|
5734
|
+
if (!segments2?.length) return authored;
|
|
5425
5735
|
const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
|
|
5426
5736
|
const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
5427
5737
|
(a, b) => a - b
|
|
5428
5738
|
);
|
|
5429
|
-
const ends = speechPlan(openSeconds(scene), usable,
|
|
5739
|
+
const ends = speechPlan(openSeconds(scene), usable, segments2).end;
|
|
5430
5740
|
return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
|
|
5431
5741
|
}
|
|
5432
5742
|
function stageScene(scene, speed) {
|
|
5433
5743
|
const paced = pace(scene, speed);
|
|
5434
5744
|
return { scene: paced, open: openSeconds(paced) };
|
|
5435
5745
|
}
|
|
5436
|
-
function speechPlan(open, holds,
|
|
5746
|
+
function speechPlan(open, holds, segments2) {
|
|
5437
5747
|
const starts = [];
|
|
5438
5748
|
let at = open;
|
|
5439
|
-
for (const [i, segment] of
|
|
5749
|
+
for (const [i, segment] of segments2.entries()) {
|
|
5440
5750
|
const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
|
|
5441
5751
|
if (i > 0) at = Math.max(at, hold);
|
|
5442
5752
|
starts.push(at);
|
|
@@ -5528,9 +5838,9 @@ function narrationIsland(narration, scenes) {
|
|
|
5528
5838
|
voice: narration.voice,
|
|
5529
5839
|
dir: narration.dir,
|
|
5530
5840
|
scenes: Object.fromEntries(
|
|
5531
|
-
Object.entries(scenes).map(([sid,
|
|
5841
|
+
Object.entries(scenes).map(([sid, segments2]) => [
|
|
5532
5842
|
sid,
|
|
5533
|
-
|
|
5843
|
+
segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
|
|
5534
5844
|
])
|
|
5535
5845
|
)
|
|
5536
5846
|
};
|
|
@@ -5540,7 +5850,22 @@ function narrationIsland(narration, scenes) {
|
|
|
5540
5850
|
${json}
|
|
5541
5851
|
</script>`;
|
|
5542
5852
|
}
|
|
5543
|
-
function
|
|
5853
|
+
function playerEmbed(beat, source) {
|
|
5854
|
+
if (beat.archetype !== "claim-figure") return void 0;
|
|
5855
|
+
const fig = source.figures.find((f) => f.id === beat.params.figureId);
|
|
5856
|
+
if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
|
|
5857
|
+
const url = embedUrl(fig.href);
|
|
5858
|
+
return url === void 0 ? void 0 : { url, title: fig.caption };
|
|
5859
|
+
}
|
|
5860
|
+
function videoIsland(clips) {
|
|
5861
|
+
if (Object.keys(clips).length === 0) return "";
|
|
5862
|
+
const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
|
|
5863
|
+
return `
|
|
5864
|
+
<script type="application/decksmith-video+json">
|
|
5865
|
+
${json}
|
|
5866
|
+
</script>`;
|
|
5867
|
+
}
|
|
5868
|
+
function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
|
|
5544
5869
|
return `<!doctype html>
|
|
5545
5870
|
<html lang="${esc(storyboard.lang)}">
|
|
5546
5871
|
<head>
|
|
@@ -5559,7 +5884,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
|
|
|
5559
5884
|
width="${format.width}"
|
|
5560
5885
|
height="${format.height}"
|
|
5561
5886
|
></hyperframes-player>
|
|
5562
|
-
${emitIsland(slides)}${narration}
|
|
5887
|
+
${emitIsland(slides)}${narration}${video}
|
|
5563
5888
|
<script>
|
|
5564
5889
|
${closeSafe(runtimeJs)}
|
|
5565
5890
|
</script>
|
|
@@ -5711,7 +6036,7 @@ function readFragments(html) {
|
|
|
5711
6036
|
return out;
|
|
5712
6037
|
}
|
|
5713
6038
|
function holdsFor(beat, source, format, theme, sid, speed) {
|
|
5714
|
-
const ctx = { source, format, theme, sid };
|
|
6039
|
+
const ctx = { source, format, theme, sid, start: 0 };
|
|
5715
6040
|
const { scene, open } = stageScene(emitScene(beat, ctx), speed);
|
|
5716
6041
|
return {
|
|
5717
6042
|
holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
@@ -5735,15 +6060,15 @@ function assertHoldsAgree(scenes, fragments) {
|
|
|
5735
6060
|
function place(scenes, spoken) {
|
|
5736
6061
|
const out = [];
|
|
5737
6062
|
for (const scene of scenes) {
|
|
5738
|
-
const
|
|
5739
|
-
if (
|
|
6063
|
+
const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
|
|
6064
|
+
if (segments2.length === 0) continue;
|
|
5740
6065
|
if (scene.holds.length === 0) {
|
|
5741
6066
|
throw new Error(
|
|
5742
6067
|
`${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
|
|
5743
6068
|
);
|
|
5744
6069
|
}
|
|
5745
|
-
const { starts } = speechPlan(scene.open, scene.holds,
|
|
5746
|
-
for (const [i, segment] of
|
|
6070
|
+
const { starts } = speechPlan(scene.open, scene.holds, segments2);
|
|
6071
|
+
for (const [i, segment] of segments2.entries()) {
|
|
5747
6072
|
const index = Math.min(segment.stop, scene.holds.length - 1);
|
|
5748
6073
|
out.push({
|
|
5749
6074
|
id: `${scene.id}.${segment.stop}`,
|
|
@@ -5759,9 +6084,9 @@ function place(scenes, spoken) {
|
|
|
5759
6084
|
}
|
|
5760
6085
|
return out;
|
|
5761
6086
|
}
|
|
5762
|
-
function assertFits(scenes,
|
|
6087
|
+
function assertFits(scenes, segments2) {
|
|
5763
6088
|
for (const scene of scenes) {
|
|
5764
|
-
const mine =
|
|
6089
|
+
const mine = segments2.filter((s) => s.scene === scene.id);
|
|
5765
6090
|
if (mine.length === 0) continue;
|
|
5766
6091
|
const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
|
|
5767
6092
|
const need = scene.open + spoken;
|
|
@@ -5805,8 +6130,8 @@ function planTiming(input) {
|
|
|
5805
6130
|
const staging = holdsFor(beat, source, format, theme, scene.id, speed);
|
|
5806
6131
|
scene.holds = staging.holds;
|
|
5807
6132
|
scene.open = staging.open;
|
|
5808
|
-
const
|
|
5809
|
-
if (
|
|
6133
|
+
const segments3 = narration?.beats[beat.id];
|
|
6134
|
+
if (segments3?.length) spoken[scene.id] = segments3;
|
|
5810
6135
|
});
|
|
5811
6136
|
const fragments = readFragments(composition);
|
|
5812
6137
|
if (fragments) {
|
|
@@ -5815,18 +6140,18 @@ function planTiming(input) {
|
|
|
5815
6140
|
fragments
|
|
5816
6141
|
);
|
|
5817
6142
|
}
|
|
5818
|
-
const
|
|
5819
|
-
assertFits(scenes,
|
|
6143
|
+
const segments2 = place(scenes, spoken);
|
|
6144
|
+
assertFits(scenes, segments2);
|
|
5820
6145
|
return {
|
|
5821
6146
|
version: 1,
|
|
5822
6147
|
width: format.width,
|
|
5823
6148
|
height: format.height,
|
|
5824
6149
|
duration: readDuration(composition),
|
|
5825
6150
|
lang: storyboard.lang,
|
|
5826
|
-
audioDir:
|
|
5827
|
-
voice:
|
|
6151
|
+
audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
|
|
6152
|
+
voice: segments2.length > 0 ? narration?.voice ?? "" : "",
|
|
5828
6153
|
scenes,
|
|
5829
|
-
segments
|
|
6154
|
+
segments: segments2
|
|
5830
6155
|
};
|
|
5831
6156
|
}
|
|
5832
6157
|
function framePlan(timing, fps) {
|
|
@@ -5838,12 +6163,12 @@ function framePlan(timing, fps) {
|
|
|
5838
6163
|
for (const scene of timing.scenes) {
|
|
5839
6164
|
const first = f(scene.start);
|
|
5840
6165
|
const last = f(scene.start + scene.duration);
|
|
5841
|
-
const
|
|
5842
|
-
if (
|
|
6166
|
+
const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
|
|
6167
|
+
if (segments2.length === 0) {
|
|
5843
6168
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
5844
6169
|
continue;
|
|
5845
6170
|
}
|
|
5846
|
-
const tail =
|
|
6171
|
+
const tail = segments2[segments2.length - 1];
|
|
5847
6172
|
const silent = f(tail.start + tail.duration);
|
|
5848
6173
|
if (silent > last) {
|
|
5849
6174
|
throw new Error(
|
|
@@ -5851,7 +6176,7 @@ function framePlan(timing, fps) {
|
|
|
5851
6176
|
);
|
|
5852
6177
|
}
|
|
5853
6178
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
5854
|
-
for (const segment of
|
|
6179
|
+
for (const segment of segments2) {
|
|
5855
6180
|
const at = f(segment.start);
|
|
5856
6181
|
audio.push({
|
|
5857
6182
|
id: segment.id,
|
|
@@ -5924,7 +6249,7 @@ function round5(n3) {
|
|
|
5924
6249
|
}
|
|
5925
6250
|
|
|
5926
6251
|
// src/source/markdown.ts
|
|
5927
|
-
import { createHash as
|
|
6252
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5928
6253
|
import remarkGfm from "remark-gfm";
|
|
5929
6254
|
import remarkMath from "remark-math";
|
|
5930
6255
|
import remarkParse from "remark-parse";
|
|
@@ -5976,6 +6301,9 @@ function parseMarkdown(md, opts = {}) {
|
|
|
5976
6301
|
for (const img of images) {
|
|
5977
6302
|
const figure = {
|
|
5978
6303
|
id: `fig${figures.length + 1}`,
|
|
6304
|
+
// Markdown carries no video: an image node is an image. A clip only
|
|
6305
|
+
// ever enters through `harvest`, which sets this itself.
|
|
6306
|
+
kind: "image",
|
|
5979
6307
|
src: img.url,
|
|
5980
6308
|
caption: caption ?? img.alt ?? "",
|
|
5981
6309
|
// 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
|
|
@@ -6011,7 +6339,7 @@ function parseMarkdown(md, opts = {}) {
|
|
|
6011
6339
|
if (mention) p.figure.mention = mention;
|
|
6012
6340
|
}
|
|
6013
6341
|
return sourceSchema.parse({
|
|
6014
|
-
id: opts.id ??
|
|
6342
|
+
id: opts.id ?? createHash3("sha256").update(md).digest("hex").slice(0, 12),
|
|
6015
6343
|
title: title2 || "Untitled",
|
|
6016
6344
|
lang: opts.lang ?? sniffLang(md),
|
|
6017
6345
|
sections,
|
|
@@ -6020,10 +6348,14 @@ function parseMarkdown(md, opts = {}) {
|
|
|
6020
6348
|
tables
|
|
6021
6349
|
});
|
|
6022
6350
|
}
|
|
6351
|
+
var SCRIPT_SHARE = 0.02;
|
|
6023
6352
|
function sniffLang(md) {
|
|
6024
|
-
|
|
6025
|
-
if (
|
|
6026
|
-
|
|
6353
|
+
const total = md.replace(/\s+/g, "").length;
|
|
6354
|
+
if (total === 0) return "en";
|
|
6355
|
+
const share2 = (re) => (md.match(re)?.length ?? 0) / total;
|
|
6356
|
+
if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
|
|
6357
|
+
if (share2(/[-ヿ]/g) >= SCRIPT_SHARE) return "ja";
|
|
6358
|
+
if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
|
|
6027
6359
|
return "en";
|
|
6028
6360
|
}
|
|
6029
6361
|
function onlyImages(kids) {
|
|
@@ -6044,7 +6376,11 @@ function mentionOf(caption, prose, at, opened) {
|
|
|
6044
6376
|
const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
|
|
6045
6377
|
if (found) return found.text;
|
|
6046
6378
|
}
|
|
6047
|
-
|
|
6379
|
+
const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
|
|
6380
|
+
return near !== void 0 && isSentence(near) ? near : void 0;
|
|
6381
|
+
}
|
|
6382
|
+
function isSentence(text2) {
|
|
6383
|
+
return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
|
|
6048
6384
|
}
|
|
6049
6385
|
function figureName(caption) {
|
|
6050
6386
|
const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
|
|
@@ -6104,91 +6440,2022 @@ function textOf(node) {
|
|
|
6104
6440
|
}
|
|
6105
6441
|
}
|
|
6106
6442
|
|
|
6107
|
-
// src/source/
|
|
6108
|
-
import { createHash as
|
|
6109
|
-
import { mkdir as
|
|
6110
|
-
import {
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
}
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6443
|
+
// src/source/harvest.ts
|
|
6444
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
6445
|
+
import { copyFile, mkdir as mkdir3, rm as rm2, stat, writeFile as writeFile3 } from "node:fs/promises";
|
|
6446
|
+
import { basename as basename2, join as join4, resolve } from "node:path";
|
|
6447
|
+
|
|
6448
|
+
// src/net/fetch.ts
|
|
6449
|
+
import { lookup as resolveHost } from "node:dns/promises";
|
|
6450
|
+
import { request as httpRequest } from "node:http";
|
|
6451
|
+
import { request as httpsRequest } from "node:https";
|
|
6452
|
+
import { isIP } from "node:net";
|
|
6453
|
+
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
|
6454
|
+
var BLOCKED_V4 = [
|
|
6455
|
+
// "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
|
|
6456
|
+
{ re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
|
|
6457
|
+
{ re: /^10\./, why: "a private address (10.0.0.0/8)" },
|
|
6458
|
+
// NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
|
|
6459
|
+
// on a host that has one, the whole /10 is the neighbourhood.
|
|
6460
|
+
{
|
|
6461
|
+
re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
6462
|
+
why: "carrier-grade NAT space (100.64.0.0/10)"
|
|
6463
|
+
},
|
|
6464
|
+
{ re: /^127\./, why: "loopback (127.0.0.0/8)" },
|
|
6465
|
+
{ re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
|
|
6466
|
+
{ re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
|
|
6467
|
+
// NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
|
|
6468
|
+
// friends. Not routable on the public internet, so a URL pointing here is
|
|
6469
|
+
// pointing at something local.
|
|
6470
|
+
{ re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
|
|
6471
|
+
{ re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
|
|
6472
|
+
// NEW: benchmarking. Reserved for test equipment, and some networks route it
|
|
6473
|
+
// internally precisely because it will never collide with anything real.
|
|
6474
|
+
{ re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
|
|
6475
|
+
];
|
|
6476
|
+
var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
|
|
6477
|
+
function isBlockedAddress(ip) {
|
|
6478
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6479
|
+
const kind = isIP(bare);
|
|
6480
|
+
if (kind === 0) return `not an IP address (${ip})`;
|
|
6481
|
+
if (kind === 4) {
|
|
6482
|
+
for (const { re, why: why2 } of BLOCKED_V4) if (re.test(bare)) return why2;
|
|
6483
|
+
return null;
|
|
6135
6484
|
}
|
|
6136
|
-
|
|
6137
|
-
return
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6485
|
+
const groups = expandV6(bare);
|
|
6486
|
+
if (groups === null) return `not an IP address (${ip})`;
|
|
6487
|
+
if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
|
|
6488
|
+
if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
|
|
6489
|
+
const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
|
|
6490
|
+
if (embedded) {
|
|
6491
|
+
const hi = groups[6] ?? 0;
|
|
6492
|
+
const lo = groups[7] ?? 0;
|
|
6493
|
+
return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
6494
|
+
}
|
|
6495
|
+
const top = groups[0] ?? 0;
|
|
6496
|
+
if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
|
|
6497
|
+
if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
|
|
6498
|
+
return null;
|
|
6146
6499
|
}
|
|
6147
|
-
function
|
|
6148
|
-
let
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6500
|
+
function expandV6(v6) {
|
|
6501
|
+
let text2 = v6;
|
|
6502
|
+
const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
|
|
6503
|
+
if (quad?.[1]) {
|
|
6504
|
+
const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
|
|
6505
|
+
const head = text2.slice(0, text2.length - quad[1].length);
|
|
6506
|
+
text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
6507
|
+
}
|
|
6508
|
+
const halves = text2.split("::");
|
|
6509
|
+
if (halves.length > 2) return null;
|
|
6510
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
6511
|
+
const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
6512
|
+
const fill = 8 - left.length - (right?.length ?? 0);
|
|
6513
|
+
if (right !== null && fill < 0) return null;
|
|
6514
|
+
const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
|
|
6515
|
+
if (parts.length !== 8) return null;
|
|
6516
|
+
const groups = parts.map((p) => Number.parseInt(p, 16));
|
|
6517
|
+
return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
|
|
6518
|
+
}
|
|
6519
|
+
function isLoopback(ip) {
|
|
6520
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6521
|
+
return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
|
|
6522
|
+
}
|
|
6523
|
+
var MAX_REDIRECTS = 5;
|
|
6524
|
+
var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
|
|
6525
|
+
var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
6526
|
+
var CREDENTIALS = ["authorization", "cookie"];
|
|
6527
|
+
async function fetchGuarded(url, opts) {
|
|
6528
|
+
const signal = AbortSignal.timeout(opts.timeoutMs);
|
|
6529
|
+
let target;
|
|
6530
|
+
try {
|
|
6531
|
+
target = new URL(url);
|
|
6532
|
+
} catch {
|
|
6533
|
+
throw new Error(`refusing ${url}: not a URL. Give an absolute http:// or https:// URL.`);
|
|
6534
|
+
}
|
|
6535
|
+
let headers = lowercased(opts.headers);
|
|
6536
|
+
for (let hop = 0; ; hop++) {
|
|
6537
|
+
if (hop > MAX_REDIRECTS) {
|
|
6538
|
+
throw new Error(
|
|
6539
|
+
`refusing ${url}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
|
|
6540
|
+
);
|
|
6153
6541
|
}
|
|
6154
|
-
const
|
|
6155
|
-
|
|
6156
|
-
if (
|
|
6157
|
-
|
|
6542
|
+
const res = await send(url, target, headers, signal, opts);
|
|
6543
|
+
const status = res.statusCode ?? 0;
|
|
6544
|
+
if (REDIRECTS.has(status)) {
|
|
6545
|
+
const location = res.headers.location;
|
|
6546
|
+
res.destroy();
|
|
6547
|
+
if (location === void 0) {
|
|
6548
|
+
throw new Error(`refusing ${url}: HTTP ${status} from ${target.href} with no Location.`);
|
|
6549
|
+
}
|
|
6550
|
+
let next;
|
|
6551
|
+
try {
|
|
6552
|
+
next = new URL(location, target);
|
|
6553
|
+
} catch {
|
|
6554
|
+
throw new Error(
|
|
6555
|
+
`refusing ${url}: HTTP ${status} to an unparseable Location (${location}).`
|
|
6556
|
+
);
|
|
6557
|
+
}
|
|
6558
|
+
const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
|
|
6559
|
+
if (!sameOrigin) headers = withoutCredentials(headers);
|
|
6560
|
+
target = next;
|
|
6158
6561
|
continue;
|
|
6159
6562
|
}
|
|
6160
|
-
if (
|
|
6161
|
-
|
|
6162
|
-
|
|
6563
|
+
if (status < 200 || status >= 300) {
|
|
6564
|
+
res.destroy();
|
|
6565
|
+
throw new Error(`${target.href}: HTTP ${status}`);
|
|
6163
6566
|
}
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6567
|
+
const contentType = (res.headers["content-type"] ?? "").trim();
|
|
6568
|
+
const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
6569
|
+
if (opts.accept && media.match(opts.accept) === null) {
|
|
6570
|
+
res.destroy();
|
|
6571
|
+
throw new Error(
|
|
6572
|
+
`refusing ${url}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
|
|
6573
|
+
);
|
|
6574
|
+
}
|
|
6575
|
+
const raw2 = await readCapped(res, opts.maxBytes, url, signal, opts.timeoutMs);
|
|
6576
|
+
const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url);
|
|
6577
|
+
return { bytes, contentType, url: target.href };
|
|
6167
6578
|
}
|
|
6168
|
-
throw new Error("JPEG has no SOF segment");
|
|
6169
|
-
}
|
|
6170
|
-
|
|
6171
|
-
// src/plan/codex.ts
|
|
6172
|
-
import { spawn } from "node:child_process";
|
|
6173
|
-
import { mkdtemp, readFile as readFile3, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
6174
|
-
import { tmpdir } from "node:os";
|
|
6175
|
-
import { join as join3 } from "node:path";
|
|
6176
|
-
import { z as z2 } from "zod";
|
|
6177
|
-
|
|
6178
|
-
// src/plan/arc.ts
|
|
6179
|
-
var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
|
|
6180
|
-
function requiredRoles(beatCount) {
|
|
6181
|
-
if (beatCount >= 8) return ARC_ROLES;
|
|
6182
|
-
if (beatCount >= 5) return ["limitations", "conclusion"];
|
|
6183
|
-
return [];
|
|
6184
|
-
}
|
|
6185
|
-
function paperArcRequested(prefs) {
|
|
6186
|
-
return prefs.genre === "paper";
|
|
6187
6579
|
}
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6580
|
+
async function send(url, target, headers, signal, opts) {
|
|
6581
|
+
const secure = target.protocol === "https:";
|
|
6582
|
+
if (!secure && target.protocol !== "http:") {
|
|
6583
|
+
throw new Error(
|
|
6584
|
+
`refusing ${url}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
|
|
6585
|
+
);
|
|
6586
|
+
}
|
|
6587
|
+
const port = Number(target.port || (secure ? 443 : 80));
|
|
6588
|
+
if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
|
|
6589
|
+
throw new Error(
|
|
6590
|
+
`refusing ${url}: port ${port} on ${target.hostname} \u2014 only 80 and 443 are fetched. A URL naming another port is naming a service, not a page.`
|
|
6591
|
+
);
|
|
6592
|
+
}
|
|
6593
|
+
const host2 = target.hostname.replace(/^\[|\]$/g, "");
|
|
6594
|
+
let addresses;
|
|
6595
|
+
try {
|
|
6596
|
+
addresses = await Promise.race([resolveHost(host2, { all: true }), rejectsOnAbort(signal)]);
|
|
6597
|
+
} catch {
|
|
6598
|
+
if (signal.aborted) throw timedOut(url, opts.timeoutMs);
|
|
6599
|
+
throw new Error(`refusing ${url}: ${host2} does not resolve.`);
|
|
6600
|
+
}
|
|
6601
|
+
const pinned = addresses[0];
|
|
6602
|
+
if (pinned === void 0) throw new Error(`refusing ${url}: ${host2} does not resolve.`);
|
|
6603
|
+
for (const { address } of addresses) {
|
|
6604
|
+
const why2 = isBlockedAddress(address);
|
|
6605
|
+
if (why2 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
|
|
6606
|
+
throw new Error(
|
|
6607
|
+
`refusing ${url}: ${host2} resolves to ${address}, which is ${why2}. Host the file somewhere this server can reach from the public internet.`
|
|
6608
|
+
);
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
const options = {
|
|
6612
|
+
hostname: host2,
|
|
6613
|
+
port,
|
|
6614
|
+
path: `${target.pathname}${target.search}`,
|
|
6615
|
+
method: "GET",
|
|
6616
|
+
// IDENTIFY OURSELVES, AND ACCEPT HTML.
|
|
6617
|
+
//
|
|
6618
|
+
// Measured against the first real page this was ever pointed at: Wikipedia
|
|
6619
|
+
// answers a request with no `user-agent` with a bare 403, and so do a great
|
|
6620
|
+
// many sites behind a CDN. A fetcher that will not say who it is looks
|
|
6621
|
+
// exactly like a scraper worth blocking, and the failure arrives as an
|
|
6622
|
+
// HTTP status with nothing in it to explain itself. Both headers are
|
|
6623
|
+
// DEFAULTS rather than overrides — a caller that sets either wins, because
|
|
6624
|
+
// the spread below them is the caller's.
|
|
6625
|
+
headers: {
|
|
6626
|
+
"user-agent": USER_AGENT,
|
|
6627
|
+
accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
|
|
6628
|
+
...headers,
|
|
6629
|
+
"accept-encoding": "gzip, br"
|
|
6630
|
+
},
|
|
6631
|
+
signal,
|
|
6632
|
+
// THE PIN. Without this line every check above is advisory: `dns.lookup`
|
|
6633
|
+
// would run a second time inside the connect and could answer differently.
|
|
6634
|
+
lookup: pin(pinned.address, pinned.family),
|
|
6635
|
+
// No connection pool. This is a one-shot against a stranger's host, and a
|
|
6636
|
+
// pooled socket outlives the call that validated it — nothing should be able
|
|
6637
|
+
// to inherit a connection this module opened.
|
|
6638
|
+
agent: false,
|
|
6639
|
+
// Say out loud which name the certificate has to match. Node derives this
|
|
6640
|
+
// from `hostname` today, but the pin means the socket is opened to a bare
|
|
6641
|
+
// address, and a future refactor that stops setting `hostname` would turn
|
|
6642
|
+
// verification off silently rather than fail.
|
|
6643
|
+
...secure && isIP(host2) === 0 ? { servername: host2 } : {}
|
|
6644
|
+
};
|
|
6645
|
+
try {
|
|
6646
|
+
return await new Promise((resolve7, reject) => {
|
|
6647
|
+
const req = (secure ? httpsRequest : httpRequest)(options, resolve7);
|
|
6648
|
+
req.on("error", reject);
|
|
6649
|
+
req.end();
|
|
6650
|
+
});
|
|
6651
|
+
} catch (err) {
|
|
6652
|
+
if (signal.aborted) throw timedOut(url, opts.timeoutMs);
|
|
6653
|
+
throw new Error(`refusing ${url}: ${target.href} could not be reached (${message(err)}).`);
|
|
6654
|
+
}
|
|
6655
|
+
}
|
|
6656
|
+
function pin(address, family) {
|
|
6657
|
+
return (_hostname, options, callback) => {
|
|
6658
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
6659
|
+
else callback(null, address, family);
|
|
6660
|
+
};
|
|
6661
|
+
}
|
|
6662
|
+
async function readCapped(res, maxBytes, url, signal, timeoutMs) {
|
|
6663
|
+
const chunks = [];
|
|
6664
|
+
let total = 0;
|
|
6665
|
+
let oversize = false;
|
|
6666
|
+
try {
|
|
6667
|
+
for await (const chunk of res) {
|
|
6668
|
+
total += chunk.length;
|
|
6669
|
+
if (total > maxBytes) {
|
|
6670
|
+
oversize = true;
|
|
6671
|
+
res.destroy();
|
|
6672
|
+
break;
|
|
6673
|
+
}
|
|
6674
|
+
chunks.push(chunk);
|
|
6675
|
+
}
|
|
6676
|
+
} catch (err) {
|
|
6677
|
+
if (signal.aborted) throw timedOut(url, timeoutMs);
|
|
6678
|
+
throw new Error(`refusing ${url}: the body stopped arriving (${message(err)}).`);
|
|
6679
|
+
}
|
|
6680
|
+
if (oversize) {
|
|
6681
|
+
throw new Error(
|
|
6682
|
+
`refusing ${url}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
|
|
6683
|
+
);
|
|
6684
|
+
}
|
|
6685
|
+
return Buffer.concat(chunks);
|
|
6686
|
+
}
|
|
6687
|
+
function decode(raw2, header, maxBytes, url) {
|
|
6688
|
+
const encoding = (header ?? "").trim().toLowerCase();
|
|
6689
|
+
if (encoding === "" || encoding === "identity") return raw2;
|
|
6690
|
+
try {
|
|
6691
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
6692
|
+
return gunzipSync(raw2, { maxOutputLength: maxBytes });
|
|
6693
|
+
}
|
|
6694
|
+
if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
|
|
6695
|
+
} catch (err) {
|
|
6696
|
+
throw new Error(
|
|
6697
|
+
`refusing ${url}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
|
|
6698
|
+
);
|
|
6699
|
+
}
|
|
6700
|
+
throw new Error(
|
|
6701
|
+
`refusing ${url}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
|
|
6702
|
+
);
|
|
6703
|
+
}
|
|
6704
|
+
function lowercased(headers) {
|
|
6705
|
+
const out = {};
|
|
6706
|
+
for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
|
|
6707
|
+
return out;
|
|
6708
|
+
}
|
|
6709
|
+
function withoutCredentials(headers) {
|
|
6710
|
+
const out = { ...headers };
|
|
6711
|
+
for (const name of CREDENTIALS) delete out[name];
|
|
6712
|
+
return out;
|
|
6713
|
+
}
|
|
6714
|
+
function rejectsOnAbort(signal) {
|
|
6715
|
+
return new Promise((_, reject) => {
|
|
6716
|
+
if (signal.aborted) reject(signal.reason);
|
|
6717
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
6718
|
+
});
|
|
6719
|
+
}
|
|
6720
|
+
function timedOut(url, timeoutMs) {
|
|
6721
|
+
return new Error(
|
|
6722
|
+
`refusing ${url}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
|
|
6723
|
+
);
|
|
6724
|
+
}
|
|
6725
|
+
function message(err) {
|
|
6726
|
+
return err instanceof Error ? err.message : String(err);
|
|
6727
|
+
}
|
|
6728
|
+
|
|
6729
|
+
// src/render/capture.ts
|
|
6730
|
+
import { homedir } from "node:os";
|
|
6731
|
+
import { join as join2 } from "node:path";
|
|
6732
|
+
async function chromePath(need = "open the deck with") {
|
|
6733
|
+
const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
|
|
6734
|
+
if (explicit) return explicit;
|
|
6735
|
+
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
6736
|
+
const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
|
|
6737
|
+
const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
|
|
6738
|
+
const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
|
|
6739
|
+
if (found) return found.executablePath;
|
|
6740
|
+
throw new Error(
|
|
6741
|
+
`no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
|
|
6742
|
+
);
|
|
6743
|
+
}
|
|
6744
|
+
|
|
6745
|
+
// src/source/assets.ts
|
|
6746
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
6747
|
+
import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
6748
|
+
import { extname as extname2, join as join3 } from "node:path";
|
|
6749
|
+
import { z as z2 } from "zod";
|
|
6750
|
+
async function fetchFigures(source, dir, warnings) {
|
|
6751
|
+
const drops = warnings ?? [];
|
|
6752
|
+
await mkdir2(dir, { recursive: true });
|
|
6753
|
+
const cached = await readdir(dir).catch(() => []);
|
|
6754
|
+
const figures = [];
|
|
6755
|
+
for (const figure of source.figures) {
|
|
6756
|
+
if (figure.kind === "clip") {
|
|
6757
|
+
figures.push(figure);
|
|
6758
|
+
continue;
|
|
6759
|
+
}
|
|
6760
|
+
try {
|
|
6761
|
+
figures.push(figureSchema.parse(await localize(figure, dir, cached)));
|
|
6762
|
+
} catch (err) {
|
|
6763
|
+
drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
|
|
6764
|
+
}
|
|
6765
|
+
}
|
|
6766
|
+
if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
|
|
6767
|
+
const parsed = sourceSchema.safeParse({ ...source, figures });
|
|
6768
|
+
if (!parsed.success) {
|
|
6769
|
+
throw new Error(
|
|
6770
|
+
`source "${source.id}" no longer parses after localising its figures: ${reason(parsed.error)}. Every figure kept was checked on its own, so the fault is in the document around them, not in an image.`
|
|
6771
|
+
);
|
|
6772
|
+
}
|
|
6773
|
+
return parsed.data;
|
|
6774
|
+
}
|
|
6775
|
+
async function localize(figure, dir, cached) {
|
|
6776
|
+
const stem = assetStem(figure.id, figure.src);
|
|
6777
|
+
const hit = cached.find((name) => name.startsWith(`${stem}.`));
|
|
6778
|
+
const bytes = hit ? await readFile3(join3(dir, hit)) : await load(figure.src);
|
|
6779
|
+
const size = imageSize(bytes);
|
|
6780
|
+
if (hit) return { ...figure, src: hit, ...size };
|
|
6781
|
+
const src = `${stem}${assetExt(bytes, figure.src)}`;
|
|
6782
|
+
await writeFile2(join3(dir, src), bytes);
|
|
6783
|
+
cached.push(src);
|
|
6784
|
+
return { ...figure, src, ...size };
|
|
6785
|
+
}
|
|
6786
|
+
function assetStem(id2, src) {
|
|
6787
|
+
const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
|
|
6788
|
+
return `${readable}-${createHash4("sha256").update(src).digest("hex").slice(0, 8)}`;
|
|
6789
|
+
}
|
|
6790
|
+
var EXT = {
|
|
6791
|
+
png: ".png",
|
|
6792
|
+
jpeg: ".jpg",
|
|
6793
|
+
gif: ".gif",
|
|
6794
|
+
webp: ".webp",
|
|
6795
|
+
avif: ".avif",
|
|
6796
|
+
svg: ".svg"
|
|
6797
|
+
};
|
|
6798
|
+
function assetExt(bytes, src) {
|
|
6799
|
+
const format = sniffFormat(bytes);
|
|
6800
|
+
if (format) return EXT[format];
|
|
6801
|
+
const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
6802
|
+
return fromUrl || ".img";
|
|
6803
|
+
}
|
|
6804
|
+
var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6805
|
+
var FIGURE_TIMEOUT_MS = 2e4;
|
|
6806
|
+
async function load(src) {
|
|
6807
|
+
if (!/^https?:/i.test(src)) return readFile3(src);
|
|
6808
|
+
const got = await fetchGuarded(src, {
|
|
6809
|
+
maxBytes: FIGURE_MAX_BYTES,
|
|
6810
|
+
timeoutMs: FIGURE_TIMEOUT_MS
|
|
6811
|
+
});
|
|
6812
|
+
return got.bytes;
|
|
6813
|
+
}
|
|
6814
|
+
function reason(err) {
|
|
6815
|
+
if (err instanceof z2.ZodError)
|
|
6816
|
+
return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
6817
|
+
return err instanceof Error ? err.message : String(err);
|
|
6818
|
+
}
|
|
6819
|
+
var SVG_WINDOW = 64 * 1024;
|
|
6820
|
+
function sniffFormat(b) {
|
|
6821
|
+
if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
|
|
6822
|
+
return "png";
|
|
6823
|
+
if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
|
|
6824
|
+
if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
|
|
6825
|
+
if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
|
|
6826
|
+
return "webp";
|
|
6827
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
|
|
6828
|
+
if (isSvg(b)) return "svg";
|
|
6829
|
+
return void 0;
|
|
6830
|
+
}
|
|
6831
|
+
function isAvifBrand(b) {
|
|
6832
|
+
const declared = b.readUInt32BE(0);
|
|
6833
|
+
const end = Math.min(declared >= 16 ? declared : b.length, b.length);
|
|
6834
|
+
const brand = (at) => b.toString("latin1", at, at + 4);
|
|
6835
|
+
if (brand(8) === "avif" || brand(8) === "avis") return true;
|
|
6836
|
+
for (let i = 16; i + 4 <= end; i += 4)
|
|
6837
|
+
if (brand(i) === "avif" || brand(i) === "avis") return true;
|
|
6838
|
+
return false;
|
|
6839
|
+
}
|
|
6840
|
+
function isSvg(b) {
|
|
6841
|
+
const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
|
|
6842
|
+
let i = 0;
|
|
6843
|
+
for (; ; ) {
|
|
6844
|
+
while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
|
|
6845
|
+
const comment = text2.startsWith("<!--", i);
|
|
6846
|
+
if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
|
|
6847
|
+
const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
|
|
6848
|
+
if (end <= 0) return false;
|
|
6849
|
+
i = end;
|
|
6850
|
+
continue;
|
|
6851
|
+
}
|
|
6852
|
+
return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
function imageSize(b) {
|
|
6856
|
+
const format = sniffFormat(b);
|
|
6857
|
+
const raw2 = measure2(b, format);
|
|
6858
|
+
const width = Math.round(raw2.width);
|
|
6859
|
+
const height = Math.round(raw2.height);
|
|
6860
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
|
|
6861
|
+
throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
|
|
6862
|
+
return { width, height };
|
|
6863
|
+
}
|
|
6864
|
+
function measure2(b, format) {
|
|
6865
|
+
switch (format) {
|
|
6866
|
+
case "png":
|
|
6867
|
+
return pngSize(b);
|
|
6868
|
+
case "gif":
|
|
6869
|
+
return gifSize(b);
|
|
6870
|
+
case "jpeg":
|
|
6871
|
+
return jpegSize(b);
|
|
6872
|
+
case "webp":
|
|
6873
|
+
return webpSize(b);
|
|
6874
|
+
case "avif":
|
|
6875
|
+
return avifSize(b);
|
|
6876
|
+
case "svg":
|
|
6877
|
+
return svgFigureSize(b);
|
|
6878
|
+
default:
|
|
6879
|
+
throw new Error(
|
|
6880
|
+
`unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
|
|
6881
|
+
);
|
|
6882
|
+
}
|
|
6883
|
+
}
|
|
6884
|
+
function describe(b) {
|
|
6885
|
+
if (b.length === 0) return "an empty file";
|
|
6886
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
|
|
6887
|
+
return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
|
|
6888
|
+
const head = b.toString("latin1", 0, Math.min(b.length, 64));
|
|
6889
|
+
if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
|
|
6890
|
+
return "an HTML page \u2014 the URL answered with a page, not a picture";
|
|
6891
|
+
if (head.startsWith("%PDF")) return "a PDF";
|
|
6892
|
+
const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
|
|
6893
|
+
return `${b.length} bytes beginning ${hex}`;
|
|
6894
|
+
}
|
|
6895
|
+
function pngSize(b) {
|
|
6896
|
+
if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
|
|
6897
|
+
if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
|
|
6898
|
+
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
|
6899
|
+
}
|
|
6900
|
+
function gifSize(b) {
|
|
6901
|
+
if (b.length < 10)
|
|
6902
|
+
throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
|
|
6903
|
+
return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
|
|
6904
|
+
}
|
|
6905
|
+
function jpegSize(b) {
|
|
6906
|
+
let i = 2;
|
|
6907
|
+
while (i + 9 < b.length) {
|
|
6908
|
+
if (b[i] !== 255) {
|
|
6909
|
+
i++;
|
|
6910
|
+
continue;
|
|
6911
|
+
}
|
|
6912
|
+
const marker = b[i + 1];
|
|
6913
|
+
if (marker === void 0) break;
|
|
6914
|
+
if (marker === 255) {
|
|
6915
|
+
i++;
|
|
6916
|
+
continue;
|
|
6917
|
+
}
|
|
6918
|
+
if (marker === 1 || marker >= 208 && marker <= 217) {
|
|
6919
|
+
i += 2;
|
|
6920
|
+
continue;
|
|
6921
|
+
}
|
|
6922
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
|
|
6923
|
+
return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
|
|
6924
|
+
i += 2 + b.readUInt16BE(i + 2);
|
|
6925
|
+
}
|
|
6926
|
+
throw new Error("JPEG has no SOF segment");
|
|
6927
|
+
}
|
|
6928
|
+
function webpSize(b) {
|
|
6929
|
+
const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
|
|
6930
|
+
if (chunk === "VP8 ") {
|
|
6931
|
+
if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
|
|
6932
|
+
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
|
|
6933
|
+
throw new Error("WebP VP8 chunk has no keyframe sync code");
|
|
6934
|
+
return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
|
|
6935
|
+
}
|
|
6936
|
+
if (chunk === "VP8L") {
|
|
6937
|
+
if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
|
|
6938
|
+
if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
|
|
6939
|
+
const bits = b.readUInt32LE(21);
|
|
6940
|
+
return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
|
|
6941
|
+
}
|
|
6942
|
+
if (chunk === "VP8X") {
|
|
6943
|
+
if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
|
|
6944
|
+
return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
|
|
6945
|
+
}
|
|
6946
|
+
throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
|
|
6947
|
+
}
|
|
6948
|
+
function avifSize(b) {
|
|
6949
|
+
const meta = find(b, 0, b.length, "meta");
|
|
6950
|
+
if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
|
|
6951
|
+
const iprp = find(b, meta.start + 4, meta.end, "iprp");
|
|
6952
|
+
const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
|
|
6953
|
+
if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
|
|
6954
|
+
let best = { width: 0, height: 0 };
|
|
6955
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
6956
|
+
if (box.type !== "ispe" || box.end - box.start < 12) continue;
|
|
6957
|
+
const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
|
|
6958
|
+
if (found.width * found.height > best.width * best.height) best = found;
|
|
6959
|
+
}
|
|
6960
|
+
if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
|
|
6961
|
+
return cleanAperture(b, ipco, best) ?? best;
|
|
6962
|
+
}
|
|
6963
|
+
function cleanAperture(b, ipco, ispe) {
|
|
6964
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
6965
|
+
if (box.type !== "clap" || box.end - box.start < 16) continue;
|
|
6966
|
+
const at = (offset) => b.readUInt32BE(box.start + offset);
|
|
6967
|
+
if (at(4) === 0 || at(12) === 0) continue;
|
|
6968
|
+
const width = Math.round(at(0) / at(4));
|
|
6969
|
+
const height = Math.round(at(8) / at(12));
|
|
6970
|
+
if (width > ispe.width || height > ispe.height) continue;
|
|
6971
|
+
if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
|
|
6972
|
+
return { width, height };
|
|
6973
|
+
}
|
|
6974
|
+
return void 0;
|
|
6975
|
+
}
|
|
6976
|
+
function find(b, from, to, type) {
|
|
6977
|
+
for (const box of boxes(b, from, to)) if (box.type === type) return box;
|
|
6978
|
+
return void 0;
|
|
6979
|
+
}
|
|
6980
|
+
function* boxes(b, from, to) {
|
|
6981
|
+
let i = from;
|
|
6982
|
+
while (i + 8 <= to) {
|
|
6983
|
+
let size = b.readUInt32BE(i);
|
|
6984
|
+
const type = b.toString("latin1", i + 4, i + 8);
|
|
6985
|
+
let start = i + 8;
|
|
6986
|
+
if (size === 1) {
|
|
6987
|
+
if (i + 16 > to) return;
|
|
6988
|
+
const large = b.readBigUInt64BE(i + 8);
|
|
6989
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
|
|
6990
|
+
size = Number(large);
|
|
6991
|
+
start = i + 16;
|
|
6992
|
+
} else if (size === 0) {
|
|
6993
|
+
size = to - i;
|
|
6994
|
+
}
|
|
6995
|
+
if (size < start - i || i + size > to) return;
|
|
6996
|
+
yield { type, start, end: i + size };
|
|
6997
|
+
i += size;
|
|
6998
|
+
}
|
|
6999
|
+
}
|
|
7000
|
+
function svgFigureSize(b) {
|
|
7001
|
+
const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
|
|
7002
|
+
b.toString("utf8")
|
|
7003
|
+
);
|
|
7004
|
+
if (moving)
|
|
7005
|
+
throw new Error(
|
|
7006
|
+
`SVG carries "${moving[0]}": animation is drawn against the capture clock, so the render would differ every run. Convert the figure to PNG and point at that.`
|
|
7007
|
+
);
|
|
7008
|
+
return svgSize(b);
|
|
7009
|
+
}
|
|
7010
|
+
function svgSize(bytes) {
|
|
7011
|
+
const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
|
|
7012
|
+
const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
|
|
7013
|
+
if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
|
|
7014
|
+
const width = cssPixels(attrOf(tag, "width"));
|
|
7015
|
+
const height = cssPixels(attrOf(tag, "height"));
|
|
7016
|
+
if (width !== void 0 && height !== void 0) return { width, height };
|
|
7017
|
+
const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
|
|
7018
|
+
const w = box?.[2];
|
|
7019
|
+
const h = box?.[3];
|
|
7020
|
+
if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
|
|
7021
|
+
return { width: w, height: h };
|
|
7022
|
+
throw new Error("SVG declares no absolute width and height, and no usable viewBox");
|
|
7023
|
+
}
|
|
7024
|
+
function attrOf(tag, name) {
|
|
7025
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
|
|
7026
|
+
return m?.[1] ?? m?.[2];
|
|
7027
|
+
}
|
|
7028
|
+
var UNIT = {
|
|
7029
|
+
"": 1,
|
|
7030
|
+
px: 1,
|
|
7031
|
+
pt: 96 / 72,
|
|
7032
|
+
pc: 16,
|
|
7033
|
+
in: 96,
|
|
7034
|
+
cm: 96 / 2.54,
|
|
7035
|
+
mm: 96 / 25.4,
|
|
7036
|
+
q: 96 / 101.6,
|
|
7037
|
+
em: 16,
|
|
7038
|
+
rem: 16
|
|
7039
|
+
};
|
|
7040
|
+
function cssPixels(value) {
|
|
7041
|
+
if (value === void 0) return void 0;
|
|
7042
|
+
const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
|
|
7043
|
+
if (!m?.[1]) return void 0;
|
|
7044
|
+
const scale = UNIT[(m[2] ?? "").toLowerCase()];
|
|
7045
|
+
if (scale === void 0) return void 0;
|
|
7046
|
+
const px = Number(m[1]) * scale;
|
|
7047
|
+
return px > 0 ? px : void 0;
|
|
7048
|
+
}
|
|
7049
|
+
|
|
7050
|
+
// src/source/readability.ts
|
|
7051
|
+
var CONTENT_MARKER = "data-ds-content";
|
|
7052
|
+
function readContentRegion() {
|
|
7053
|
+
const MARKER = "data-ds-content";
|
|
7054
|
+
const MIN_TEXT = 250;
|
|
7055
|
+
const MAX_ANCESTORS = 5;
|
|
7056
|
+
const SIBLING_FRACTION = 0.2;
|
|
7057
|
+
const MIN_SIBLING_SCORE = 10;
|
|
7058
|
+
const MIN_PARAGRAPH = 25;
|
|
7059
|
+
const UNLIKELY = /-ad-|ai2html|banner|breadcrumb|combx|comment|community|consent|cookie|disqus|extra|footer|gdpr|legends|masthead|menu|modal|newsletter|paywall|popup|promo|related|remark|replies|rss|share|shoutbox|sidebar|skyscraper|social|sponsor|subscribe|supplemental|pagination|pager|toolbar|yom-remote/i;
|
|
7060
|
+
const MAYBE = /and|article|body|column|content|main|shadow|story/i;
|
|
7061
|
+
const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
|
|
7062
|
+
const NEGATIVE = /-ad-|banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i;
|
|
7063
|
+
const CHROME_ROLES = /* @__PURE__ */ new Set([
|
|
7064
|
+
"alert",
|
|
7065
|
+
"alertdialog",
|
|
7066
|
+
"banner",
|
|
7067
|
+
"complementary",
|
|
7068
|
+
"contentinfo",
|
|
7069
|
+
"dialog",
|
|
7070
|
+
"menu",
|
|
7071
|
+
"menubar",
|
|
7072
|
+
"navigation",
|
|
7073
|
+
"search",
|
|
7074
|
+
"toolbar",
|
|
7075
|
+
"tooltip"
|
|
7076
|
+
]);
|
|
7077
|
+
const CHROME_TAGS = /* @__PURE__ */ new Set([
|
|
7078
|
+
"nav",
|
|
7079
|
+
"aside",
|
|
7080
|
+
"footer",
|
|
7081
|
+
"script",
|
|
7082
|
+
"style",
|
|
7083
|
+
"noscript",
|
|
7084
|
+
"form",
|
|
7085
|
+
"svg",
|
|
7086
|
+
"canvas",
|
|
7087
|
+
"template",
|
|
7088
|
+
"button",
|
|
7089
|
+
"select",
|
|
7090
|
+
"textarea"
|
|
7091
|
+
]);
|
|
7092
|
+
const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
|
|
7093
|
+
function textOf2(node) {
|
|
7094
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
7095
|
+
}
|
|
7096
|
+
function labelOf(el) {
|
|
7097
|
+
return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
|
|
7098
|
+
}
|
|
7099
|
+
function nameOf(el) {
|
|
7100
|
+
const id2 = el.getAttribute("id");
|
|
7101
|
+
const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
|
|
7102
|
+
return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
|
|
7103
|
+
}
|
|
7104
|
+
function pathOf(el) {
|
|
7105
|
+
const parts = [];
|
|
7106
|
+
let node = el;
|
|
7107
|
+
while (node && parts.length < 3) {
|
|
7108
|
+
parts.unshift(nameOf(node));
|
|
7109
|
+
node = node.parentElement;
|
|
7110
|
+
}
|
|
7111
|
+
return parts.join(" > ");
|
|
7112
|
+
}
|
|
7113
|
+
function round6(n3) {
|
|
7114
|
+
return Math.round(n3 * 1e3) / 1e3;
|
|
7115
|
+
}
|
|
7116
|
+
function linkDensity(el) {
|
|
7117
|
+
const total = textOf2(el).length;
|
|
7118
|
+
if (total === 0) return 0;
|
|
7119
|
+
let inLinks = 0;
|
|
7120
|
+
for (const a of el.querySelectorAll("a")) {
|
|
7121
|
+
const href = a.getAttribute("href") ?? "";
|
|
7122
|
+
inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
|
|
7123
|
+
}
|
|
7124
|
+
return inLinks / total;
|
|
7125
|
+
}
|
|
7126
|
+
function classWeight(el) {
|
|
7127
|
+
let weight = 0;
|
|
7128
|
+
for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
|
|
7129
|
+
if (label === "") continue;
|
|
7130
|
+
if (NEGATIVE.test(label)) weight -= 25;
|
|
7131
|
+
if (POSITIVE.test(label)) weight += 25;
|
|
7132
|
+
}
|
|
7133
|
+
return weight;
|
|
7134
|
+
}
|
|
7135
|
+
function tagBonus(tag) {
|
|
7136
|
+
if (tag === "div") return 5;
|
|
7137
|
+
if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
|
|
7138
|
+
if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
|
|
7139
|
+
if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
|
|
7140
|
+
return 0;
|
|
7141
|
+
}
|
|
7142
|
+
const scores = /* @__PURE__ */ new Map();
|
|
7143
|
+
function scoreOf(el) {
|
|
7144
|
+
const at = scores.get(el);
|
|
7145
|
+
if (at !== void 0) return at;
|
|
7146
|
+
const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
|
|
7147
|
+
scores.set(el, start);
|
|
7148
|
+
return start;
|
|
7149
|
+
}
|
|
7150
|
+
const removed = [];
|
|
7151
|
+
function strip(el) {
|
|
7152
|
+
const parent = el.parentElement;
|
|
7153
|
+
if (!parent) return;
|
|
7154
|
+
removed.push({ node: el, parent, next: el.nextSibling });
|
|
7155
|
+
el.remove();
|
|
7156
|
+
}
|
|
7157
|
+
function restore() {
|
|
7158
|
+
for (let i = removed.length - 1; i >= 0; i--) {
|
|
7159
|
+
const at = removed[i];
|
|
7160
|
+
if (at) at.parent.insertBefore(at.node, at.next);
|
|
7161
|
+
}
|
|
7162
|
+
removed.length = 0;
|
|
7163
|
+
}
|
|
7164
|
+
function declined(reason3, candidates2) {
|
|
7165
|
+
restore();
|
|
7166
|
+
return {
|
|
7167
|
+
marked: false,
|
|
7168
|
+
reason: reason3,
|
|
7169
|
+
candidates: candidates2,
|
|
7170
|
+
merged: 0,
|
|
7171
|
+
stripped: 0,
|
|
7172
|
+
text: 0,
|
|
7173
|
+
mediaDropped: 0
|
|
7174
|
+
};
|
|
7175
|
+
}
|
|
7176
|
+
try {
|
|
7177
|
+
if (!document.body) {
|
|
7178
|
+
return declined("the document has no <body>, so there is no region to choose", []);
|
|
7179
|
+
}
|
|
7180
|
+
const mediaBefore = document.querySelectorAll("video, iframe").length;
|
|
7181
|
+
for (const el of [...document.body.querySelectorAll("*")]) {
|
|
7182
|
+
if (!el.isConnected) continue;
|
|
7183
|
+
const tag = el.tagName.toLowerCase();
|
|
7184
|
+
if (CHROME_TAGS.has(tag)) {
|
|
7185
|
+
strip(el);
|
|
7186
|
+
continue;
|
|
7187
|
+
}
|
|
7188
|
+
if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
|
|
7189
|
+
strip(el);
|
|
7190
|
+
continue;
|
|
7191
|
+
}
|
|
7192
|
+
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
|
|
7193
|
+
strip(el);
|
|
7194
|
+
continue;
|
|
7195
|
+
}
|
|
7196
|
+
if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
|
|
7197
|
+
strip(el);
|
|
7198
|
+
continue;
|
|
7199
|
+
}
|
|
7200
|
+
const label = labelOf(el);
|
|
7201
|
+
if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
|
|
7202
|
+
strip(el);
|
|
7203
|
+
}
|
|
7204
|
+
}
|
|
7205
|
+
for (const el of document.body.querySelectorAll(SCORED)) {
|
|
7206
|
+
const text3 = textOf2(el);
|
|
7207
|
+
if (text3.length < MIN_PARAGRAPH) continue;
|
|
7208
|
+
const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
|
|
7209
|
+
let node = el.parentElement;
|
|
7210
|
+
let level = 0;
|
|
7211
|
+
while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
|
|
7212
|
+
const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
|
|
7213
|
+
scores.set(node, scoreOf(node) + base / divider);
|
|
7214
|
+
node = node.parentElement;
|
|
7215
|
+
level++;
|
|
7216
|
+
}
|
|
7217
|
+
}
|
|
7218
|
+
const ranked = [];
|
|
7219
|
+
for (const [el, content] of scores) {
|
|
7220
|
+
const density = linkDensity(el);
|
|
7221
|
+
ranked.push({
|
|
7222
|
+
el,
|
|
7223
|
+
report: {
|
|
7224
|
+
path: pathOf(el),
|
|
7225
|
+
content: round6(content),
|
|
7226
|
+
linkDensity: round6(density),
|
|
7227
|
+
// THE WHOLE POINT OF THE PASS. A rail of related headlines and an
|
|
7228
|
+
// article of the same length hold the same number of characters; only
|
|
7229
|
+
// this term separates them, and it is why the discount is a
|
|
7230
|
+
// multiplier rather than a subtraction — a candidate that is entirely
|
|
7231
|
+
// links scores zero however long it is.
|
|
7232
|
+
score: round6(content * (1 - density)),
|
|
7233
|
+
text: textOf2(el).length
|
|
7234
|
+
}
|
|
7235
|
+
});
|
|
7236
|
+
}
|
|
7237
|
+
ranked.sort((a, b) => b.report.score - a.report.score);
|
|
7238
|
+
const report2 = ranked.slice(0, 5).map((r) => r.report);
|
|
7239
|
+
let top = ranked[0];
|
|
7240
|
+
if (!top) {
|
|
7241
|
+
return declined(
|
|
7242
|
+
`nothing scored: the page has no paragraph of ${MIN_PARAGRAPH} characters or more outside its chrome. Save the page and ingest the file if a script builds its body.`,
|
|
7243
|
+
report2
|
|
7244
|
+
);
|
|
7245
|
+
}
|
|
7246
|
+
const byElement = new Map(ranked.map((r) => [r.el, r]));
|
|
7247
|
+
const floor = top.report.score / 3;
|
|
7248
|
+
let last = top.report.score;
|
|
7249
|
+
let up = top.el.parentElement;
|
|
7250
|
+
while (up && up !== document.body && up !== document.documentElement) {
|
|
7251
|
+
const here = byElement.get(up);
|
|
7252
|
+
if (here) {
|
|
7253
|
+
if (here.report.score < floor) break;
|
|
7254
|
+
if (here.report.score > last) {
|
|
7255
|
+
top = here;
|
|
7256
|
+
break;
|
|
7257
|
+
}
|
|
7258
|
+
last = here.report.score;
|
|
7259
|
+
}
|
|
7260
|
+
up = up.parentElement;
|
|
7261
|
+
}
|
|
7262
|
+
if (top.el === document.body) {
|
|
7263
|
+
const text3 = textOf2(document.body).length;
|
|
7264
|
+
if (text3 < MIN_TEXT) {
|
|
7265
|
+
return declined(
|
|
7266
|
+
`the page holds ${text3} characters of text outside its chrome, under the ${MIN_TEXT} this pass needs to be sure of a region; left it for the simpler heuristic`,
|
|
7267
|
+
report2
|
|
7268
|
+
);
|
|
7269
|
+
}
|
|
7270
|
+
document.body.setAttribute(MARKER, "");
|
|
7271
|
+
return {
|
|
7272
|
+
marked: true,
|
|
7273
|
+
reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
|
|
7274
|
+
candidates: report2,
|
|
7275
|
+
merged: 0,
|
|
7276
|
+
stripped: removed.length,
|
|
7277
|
+
text: text3,
|
|
7278
|
+
mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
|
|
7279
|
+
};
|
|
7280
|
+
}
|
|
7281
|
+
const parent = top.el.parentElement;
|
|
7282
|
+
if (!parent) {
|
|
7283
|
+
return declined("the winning candidate is not in the document any more", report2);
|
|
7284
|
+
}
|
|
7285
|
+
const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
|
|
7286
|
+
const topClass = top.el.getAttribute("class") ?? "";
|
|
7287
|
+
const keep = [];
|
|
7288
|
+
for (const sib of [...parent.children]) {
|
|
7289
|
+
if (sib === top.el) {
|
|
7290
|
+
keep.push(sib);
|
|
7291
|
+
continue;
|
|
7292
|
+
}
|
|
7293
|
+
const twin = topClass !== "" && sib.getAttribute("class") === topClass;
|
|
7294
|
+
const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
|
|
7295
|
+
const here = byElement.get(sib);
|
|
7296
|
+
if (here && here.report.score + bonus >= threshold) {
|
|
7297
|
+
keep.push(sib);
|
|
7298
|
+
continue;
|
|
7299
|
+
}
|
|
7300
|
+
if (sib.tagName.toLowerCase() === "p") {
|
|
7301
|
+
const text3 = textOf2(sib);
|
|
7302
|
+
const density = linkDensity(sib);
|
|
7303
|
+
const long = text3.length > 80 && density < 0.25;
|
|
7304
|
+
const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
|
|
7305
|
+
if (long || sentence) keep.push(sib);
|
|
7306
|
+
}
|
|
7307
|
+
}
|
|
7308
|
+
const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
|
|
7309
|
+
if (text2 < MIN_TEXT) {
|
|
7310
|
+
return declined(
|
|
7311
|
+
`the best region (${top.report.path}) holds ${text2} characters, under the ${MIN_TEXT} this pass needs to be sure of it; left the page for the simpler heuristic`,
|
|
7312
|
+
report2
|
|
7313
|
+
);
|
|
7314
|
+
}
|
|
7315
|
+
let region;
|
|
7316
|
+
if (keep.length === 1 && keep[0]) {
|
|
7317
|
+
region = keep[0];
|
|
7318
|
+
} else {
|
|
7319
|
+
const box = document.createElement("div");
|
|
7320
|
+
for (const el of keep) box.appendChild(el);
|
|
7321
|
+
document.body.appendChild(box);
|
|
7322
|
+
region = box;
|
|
7323
|
+
}
|
|
7324
|
+
region.setAttribute(MARKER, "");
|
|
7325
|
+
return {
|
|
7326
|
+
marked: true,
|
|
7327
|
+
reason: `chose ${top.report.path} \u2014 score ${top.report.score}, link density ${top.report.linkDensity}, ${textOf2(region).length} characters` + (keep.length > 1 ? `, with ${keep.length - 1} sibling(s) merged in` : ""),
|
|
7328
|
+
candidates: report2,
|
|
7329
|
+
merged: keep.length - 1,
|
|
7330
|
+
stripped: removed.length,
|
|
7331
|
+
text: textOf2(region).length,
|
|
7332
|
+
mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
|
|
7333
|
+
};
|
|
7334
|
+
} catch (e) {
|
|
7335
|
+
return declined(
|
|
7336
|
+
`the readability pass failed inside the page (${e instanceof Error ? e.message : String(e)}); the page was left as it was found and the simpler heuristic still applies`,
|
|
7337
|
+
[]
|
|
7338
|
+
);
|
|
7339
|
+
}
|
|
7340
|
+
}
|
|
7341
|
+
|
|
7342
|
+
// src/source/transcode.ts
|
|
7343
|
+
import { readFile as readFile4, rm } from "node:fs/promises";
|
|
7344
|
+
import { basename } from "node:path";
|
|
7345
|
+
|
|
7346
|
+
// src/render/ffmpeg.ts
|
|
7347
|
+
import { execFile, spawn } from "node:child_process";
|
|
7348
|
+
import { promisify } from "node:util";
|
|
7349
|
+
var run = promisify(execFile);
|
|
7350
|
+
var DEFAULT_TIMEOUT_MS = 36e5;
|
|
7351
|
+
async function runTool(file, args, opts = {}) {
|
|
7352
|
+
try {
|
|
7353
|
+
return await run(file, args, {
|
|
7354
|
+
cwd: opts.cwd,
|
|
7355
|
+
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
7356
|
+
maxBuffer: 64 << 20
|
|
7357
|
+
});
|
|
7358
|
+
} catch (err) {
|
|
7359
|
+
const e = err;
|
|
7360
|
+
if (e.code === "ENOENT") {
|
|
7361
|
+
throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
|
|
7362
|
+
}
|
|
7363
|
+
const tail = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
|
|
7364
|
+
throw new Error(`${file} failed:
|
|
7365
|
+
${tail}`);
|
|
7366
|
+
}
|
|
7367
|
+
}
|
|
7368
|
+
function runLive(file, args) {
|
|
7369
|
+
return new Promise((resolve7, reject) => {
|
|
7370
|
+
const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
7371
|
+
child.on("error", (err) => {
|
|
7372
|
+
reject(
|
|
7373
|
+
err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
|
|
7374
|
+
);
|
|
7375
|
+
});
|
|
7376
|
+
child.on("close", (code, signal) => {
|
|
7377
|
+
if (code === 0) resolve7();
|
|
7378
|
+
else if (signal) {
|
|
7379
|
+
reject(
|
|
7380
|
+
new Error(
|
|
7381
|
+
`${file} was killed by ${signal}. On a machine under memory pressure this is the OS reclaiming the browser; free memory, or lower --workers, and run again.`
|
|
7382
|
+
)
|
|
7383
|
+
);
|
|
7384
|
+
} else reject(new Error(`${file} exited ${code}.`));
|
|
7385
|
+
});
|
|
7386
|
+
});
|
|
7387
|
+
}
|
|
7388
|
+
async function probe(path2) {
|
|
7389
|
+
const { stdout } = await runTool("ffprobe", [
|
|
7390
|
+
"-v",
|
|
7391
|
+
"error",
|
|
7392
|
+
"-show_entries",
|
|
7393
|
+
"stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
|
|
7394
|
+
"-of",
|
|
7395
|
+
"json",
|
|
7396
|
+
path2
|
|
7397
|
+
]);
|
|
7398
|
+
const json = JSON.parse(stdout);
|
|
7399
|
+
const streams = json.streams ?? [];
|
|
7400
|
+
const video = streams.find((s) => s.codec_type === "video");
|
|
7401
|
+
if (!video) throw new Error(`${path2} has no video stream.`);
|
|
7402
|
+
const [num2, den] = (video.r_frame_rate ?? "30/1").split("/");
|
|
7403
|
+
const fps = Number(num2) / (Number(den) || 1);
|
|
7404
|
+
const seconds = Number(json.format?.duration ?? 0);
|
|
7405
|
+
const frames = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
|
|
7406
|
+
return {
|
|
7407
|
+
width: video.width ?? 0,
|
|
7408
|
+
height: video.height ?? 0,
|
|
7409
|
+
fps,
|
|
7410
|
+
frames,
|
|
7411
|
+
seconds,
|
|
7412
|
+
hasAudio: streams.some((s) => s.codec_type === "audio")
|
|
7413
|
+
};
|
|
7414
|
+
}
|
|
7415
|
+
function encoderArgs(fps) {
|
|
7416
|
+
return [
|
|
7417
|
+
"-c:v",
|
|
7418
|
+
"libx264",
|
|
7419
|
+
"-preset",
|
|
7420
|
+
"veryfast",
|
|
7421
|
+
"-crf",
|
|
7422
|
+
"16",
|
|
7423
|
+
"-pix_fmt",
|
|
7424
|
+
"yuv420p",
|
|
7425
|
+
"-g",
|
|
7426
|
+
"12",
|
|
7427
|
+
"-r",
|
|
7428
|
+
String(fps),
|
|
7429
|
+
"-fps_mode",
|
|
7430
|
+
"cfr"
|
|
7431
|
+
];
|
|
7432
|
+
}
|
|
7433
|
+
function pieceFilter(motion, freeze) {
|
|
7434
|
+
const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
|
|
7435
|
+
if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
|
|
7436
|
+
return chain.join(",");
|
|
7437
|
+
}
|
|
7438
|
+
function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
|
|
7439
|
+
return [
|
|
7440
|
+
"-y",
|
|
7441
|
+
"-hide_banner",
|
|
7442
|
+
"-loglevel",
|
|
7443
|
+
"error",
|
|
7444
|
+
// Half a frame in, so a time that is exactly on a boundary cannot round to
|
|
7445
|
+
// the frame before it.
|
|
7446
|
+
"-ss",
|
|
7447
|
+
((fromFrame + 0.5) / fps).toFixed(6),
|
|
7448
|
+
"-i",
|
|
7449
|
+
source,
|
|
7450
|
+
"-an",
|
|
7451
|
+
"-vf",
|
|
7452
|
+
pieceFilter(motion, freeze),
|
|
7453
|
+
"-frames:v",
|
|
7454
|
+
String(motion + freeze),
|
|
7455
|
+
...encoderArgs(fps),
|
|
7456
|
+
"-f",
|
|
7457
|
+
"mpegts",
|
|
7458
|
+
out
|
|
7459
|
+
];
|
|
7460
|
+
}
|
|
7461
|
+
var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
|
|
7462
|
+
function audioGraph(inputs, seconds, first = 1) {
|
|
7463
|
+
const lines = inputs.map(
|
|
7464
|
+
(input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
|
|
7465
|
+
);
|
|
7466
|
+
const labels = inputs.map((_, i) => `[d${i}]`).join("");
|
|
7467
|
+
lines.push(
|
|
7468
|
+
`${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
|
|
7469
|
+
);
|
|
7470
|
+
return lines.join(";\n");
|
|
7471
|
+
}
|
|
7472
|
+
function respeedArgs(source, factor, chain, fps, hasAudio, out) {
|
|
7473
|
+
const video = `[0:v]setpts=PTS/${factor}[v]`;
|
|
7474
|
+
const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
|
|
7475
|
+
return [
|
|
7476
|
+
"-y",
|
|
7477
|
+
"-hide_banner",
|
|
7478
|
+
"-loglevel",
|
|
7479
|
+
"error",
|
|
7480
|
+
"-i",
|
|
7481
|
+
source,
|
|
7482
|
+
"-filter_complex",
|
|
7483
|
+
`${video}${audio}`,
|
|
7484
|
+
"-map",
|
|
7485
|
+
"[v]",
|
|
7486
|
+
...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
|
|
7487
|
+
...encoderArgs(fps),
|
|
7488
|
+
out
|
|
7489
|
+
];
|
|
7490
|
+
}
|
|
7491
|
+
function burnStyle(width, height, font = "Arial") {
|
|
7492
|
+
return {
|
|
7493
|
+
width,
|
|
7494
|
+
height,
|
|
7495
|
+
font,
|
|
7496
|
+
// MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
|
|
7497
|
+
// breaks it near the middle, so the longer of the two lines runs to about
|
|
7498
|
+
// 46 characters in real caption prose. Bold Arial advances 0.485em per
|
|
7499
|
+
// character on that prose (measured in a browser over the demo's own
|
|
7500
|
+
// narration), so 46 characters at F px is 22.3F wide, and the usable width
|
|
7501
|
+
// here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
|
|
7502
|
+
// "4% of the width" looked like on paper — overflows to a THIRD line, and a
|
|
7503
|
+
// three-line band covers the bottom of the slide.
|
|
7504
|
+
fontSize: Math.round(width * 0.037),
|
|
7505
|
+
// Clear of the play button, the progress bar and the handle every vertical
|
|
7506
|
+
// player draws across the bottom eighth of the frame.
|
|
7507
|
+
marginV: Math.round(height * 0.09),
|
|
7508
|
+
marginX: Math.round(width * 0.04)
|
|
7509
|
+
};
|
|
7510
|
+
}
|
|
7511
|
+
|
|
7512
|
+
// src/source/transcode.ts
|
|
7513
|
+
var CLIP_EDGE_PX = 1280;
|
|
7514
|
+
var MAX_CLIP_SECONDS = 60;
|
|
7515
|
+
var TIMEOUT_MS = 6e5;
|
|
7516
|
+
function fitBox(width, height, maxEdgePx) {
|
|
7517
|
+
const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
|
|
7518
|
+
return { width: even(width * scale), height: even(height * scale) };
|
|
7519
|
+
}
|
|
7520
|
+
function even(n3) {
|
|
7521
|
+
return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
|
|
7522
|
+
}
|
|
7523
|
+
function transcodeArgs(input, out, plan) {
|
|
7524
|
+
return [
|
|
7525
|
+
"-y",
|
|
7526
|
+
"-hide_banner",
|
|
7527
|
+
"-loglevel",
|
|
7528
|
+
"error",
|
|
7529
|
+
"-i",
|
|
7530
|
+
input,
|
|
7531
|
+
...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
|
|
7532
|
+
"-an",
|
|
7533
|
+
"-map_metadata",
|
|
7534
|
+
"-1",
|
|
7535
|
+
"-vf",
|
|
7536
|
+
`scale=${plan.width}:${plan.height}`,
|
|
7537
|
+
"-c:v",
|
|
7538
|
+
"libvpx-vp9",
|
|
7539
|
+
"-crf",
|
|
7540
|
+
"32",
|
|
7541
|
+
"-b:v",
|
|
7542
|
+
"0",
|
|
7543
|
+
"-deadline",
|
|
7544
|
+
"good",
|
|
7545
|
+
"-cpu-used",
|
|
7546
|
+
"4",
|
|
7547
|
+
"-row-mt",
|
|
7548
|
+
"1",
|
|
7549
|
+
"-pix_fmt",
|
|
7550
|
+
"yuv420p",
|
|
7551
|
+
"-fflags",
|
|
7552
|
+
"+bitexact",
|
|
7553
|
+
"-flags:v",
|
|
7554
|
+
"+bitexact",
|
|
7555
|
+
out
|
|
7556
|
+
];
|
|
7557
|
+
}
|
|
7558
|
+
var probed = /* @__PURE__ */ new Map();
|
|
7559
|
+
function installed(file) {
|
|
7560
|
+
const asked = probed.get(file);
|
|
7561
|
+
if (asked !== void 0) return asked;
|
|
7562
|
+
const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
|
|
7563
|
+
() => true,
|
|
7564
|
+
() => false
|
|
7565
|
+
);
|
|
7566
|
+
probed.set(file, answer);
|
|
7567
|
+
return answer;
|
|
7568
|
+
}
|
|
7569
|
+
async function transcode(input, out, opts = {}) {
|
|
7570
|
+
const file = opts.ffmpeg ?? "ffmpeg";
|
|
7571
|
+
const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
|
|
7572
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
7573
|
+
const name = basename(input);
|
|
7574
|
+
const source = videoSize(await readFile4(input));
|
|
7575
|
+
const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
|
|
7576
|
+
const kept = (why2) => ({
|
|
7577
|
+
path: input,
|
|
7578
|
+
width: source.width,
|
|
7579
|
+
height: source.height,
|
|
7580
|
+
seconds: source.seconds,
|
|
7581
|
+
transcoded: false,
|
|
7582
|
+
warnings: [`${name} was shipped as the page served it: ${why2}`]
|
|
7583
|
+
});
|
|
7584
|
+
if (!await installed(file)) {
|
|
7585
|
+
return kept(
|
|
7586
|
+
`${file} is not installed, or not on PATH, so it could not be shrunk to ${box.width}x${box.height} VP9 \u2014 \`brew install ffmpeg\` / \`apt install ffmpeg\` and re-ingest to spend fewer bytes and a shorter render on it.`
|
|
7587
|
+
);
|
|
7588
|
+
}
|
|
7589
|
+
const trim = source.seconds === void 0 || source.seconds > maxSeconds;
|
|
7590
|
+
const started = Date.now();
|
|
7591
|
+
try {
|
|
7592
|
+
await runTool(
|
|
7593
|
+
file,
|
|
7594
|
+
transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
|
|
7595
|
+
{ timeoutMs }
|
|
7596
|
+
);
|
|
7597
|
+
} catch (err) {
|
|
7598
|
+
await rm(out, { force: true });
|
|
7599
|
+
const over = Date.now() - started >= timeoutMs;
|
|
7600
|
+
return kept(
|
|
7601
|
+
over ? `${file} ran past its ${(timeoutMs / 1e3).toFixed(0)}s budget and was stopped (raise timeoutMs, or cap the clip harder with maxSeconds).` : `${file} failed \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
7602
|
+
);
|
|
7603
|
+
}
|
|
7604
|
+
let measured;
|
|
7605
|
+
try {
|
|
7606
|
+
measured = videoSize(await readFile4(out));
|
|
7607
|
+
} catch (err) {
|
|
7608
|
+
await rm(out, { force: true });
|
|
7609
|
+
return kept(
|
|
7610
|
+
`the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
7611
|
+
);
|
|
7612
|
+
}
|
|
7613
|
+
const warnings = [];
|
|
7614
|
+
if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
|
|
7615
|
+
warnings.push(
|
|
7616
|
+
`${name} was trimmed to the ${maxSeconds}s cap (maxSeconds) from ${source.seconds === void 0 ? "an unstated length" : `${source.seconds.toFixed(1)}s`}: a clip is pre-decoded to one still per output frame, so everything past the cap is bytes and decode that no beat is long enough to reach.`
|
|
7617
|
+
);
|
|
7618
|
+
}
|
|
7619
|
+
return {
|
|
7620
|
+
path: out,
|
|
7621
|
+
width: measured.width,
|
|
7622
|
+
height: measured.height,
|
|
7623
|
+
seconds: measured.seconds,
|
|
7624
|
+
transcoded: true,
|
|
7625
|
+
warnings
|
|
7626
|
+
};
|
|
7627
|
+
}
|
|
7628
|
+
|
|
7629
|
+
// src/source/harvest.ts
|
|
7630
|
+
var HTML_MAX_BYTES = 8 * 1024 * 1024;
|
|
7631
|
+
var ASSET_MAX_BYTES = 32 * 1024 * 1024;
|
|
7632
|
+
var TIMEOUT_MS2 = 2e4;
|
|
7633
|
+
var MAX_ASSETS = 40;
|
|
7634
|
+
var MAX_CLIPS = 4;
|
|
7635
|
+
var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
|
|
7636
|
+
var MAX_WALL_MS = 18e4;
|
|
7637
|
+
var MIN_FIGURE_PX = 64;
|
|
7638
|
+
var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
|
|
7639
|
+
async function harvest(url, dir, opts = {}) {
|
|
7640
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
|
|
7641
|
+
const warnings = [];
|
|
7642
|
+
const assets = resolve(dir);
|
|
7643
|
+
await mkdir3(assets, { recursive: true });
|
|
7644
|
+
const page = await fetchGuarded(url, {
|
|
7645
|
+
maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
|
|
7646
|
+
timeoutMs,
|
|
7647
|
+
accept: HTML_TYPES,
|
|
7648
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
7649
|
+
});
|
|
7650
|
+
const html = decodeHtml(page.bytes, page.contentType, url, warnings);
|
|
7651
|
+
const { seen, pick } = await readInBrowser(html, timeoutMs);
|
|
7652
|
+
if (!pick.marked) {
|
|
7653
|
+
warnings.push(
|
|
7654
|
+
`the article could not be scored out of this page \u2014 ${pick.reason}. The densest container was taken instead: read the section count below, and if it is the whole page rather than the piece, save the article and ingest the file.`
|
|
7655
|
+
);
|
|
7656
|
+
} else if (pick.mediaDropped > 0) {
|
|
7657
|
+
warnings.push(
|
|
7658
|
+
`${pick.mediaDropped} video or embed(s) sat outside the article region and were dropped with the page's chrome. If one of them was the video the piece is about, ingest the saved page instead, where the region is the whole document.`
|
|
7659
|
+
);
|
|
7660
|
+
}
|
|
7661
|
+
const base = absolute(seen.base, page.url) ?? page.url;
|
|
7662
|
+
const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
|
|
7663
|
+
return {
|
|
7664
|
+
markdown: toMarkdown(titled(seen.title, local.blocks)),
|
|
7665
|
+
assets: local.assets,
|
|
7666
|
+
clips: local.clips,
|
|
7667
|
+
warnings,
|
|
7668
|
+
title: seen.title
|
|
7669
|
+
};
|
|
7670
|
+
}
|
|
7671
|
+
function titled(title2, blocks) {
|
|
7672
|
+
const first = blocks[0];
|
|
7673
|
+
const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
|
|
7674
|
+
return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
|
|
7675
|
+
}
|
|
7676
|
+
function decodeHtml(bytes, contentType, url, warnings) {
|
|
7677
|
+
const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
|
|
7678
|
+
const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
|
|
7679
|
+
const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
|
|
7680
|
+
const label = declared ?? meta;
|
|
7681
|
+
if (label === void 0) return bytes.toString("utf8");
|
|
7682
|
+
try {
|
|
7683
|
+
return new TextDecoder(label).decode(bytes);
|
|
7684
|
+
} catch {
|
|
7685
|
+
warnings.push(
|
|
7686
|
+
`${url} declares charset "${label}", which this Node cannot decode; read as UTF-8 instead. Non-ASCII text may be wrong \u2014 serve the page as UTF-8, or save it and ingest the file.`
|
|
7687
|
+
);
|
|
7688
|
+
return bytes.toString("utf8");
|
|
7689
|
+
}
|
|
7690
|
+
}
|
|
7691
|
+
async function readInBrowser(html, timeoutMs) {
|
|
7692
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
7693
|
+
const browser = await puppeteer.launch({
|
|
7694
|
+
executablePath: await chromePath("read the page with"),
|
|
7695
|
+
headless: true,
|
|
7696
|
+
// Chrome's own background traffic — variations, safe browsing, first-run
|
|
7697
|
+
// pings — never goes through page interception, so it is switched off here
|
|
7698
|
+
// rather than assumed absent. It is not an SSRF path, but "the browser makes
|
|
7699
|
+
// no requests" should be true of the whole process, not just of the tab.
|
|
7700
|
+
args: [
|
|
7701
|
+
"--disable-background-networking",
|
|
7702
|
+
"--disable-extensions",
|
|
7703
|
+
"--no-default-browser-check",
|
|
7704
|
+
"--no-first-run"
|
|
7705
|
+
]
|
|
7706
|
+
});
|
|
7707
|
+
try {
|
|
7708
|
+
const page = await browser.newPage();
|
|
7709
|
+
await page.setRequestInterception(true);
|
|
7710
|
+
page.on("request", (request) => {
|
|
7711
|
+
request.abort().catch(() => {
|
|
7712
|
+
});
|
|
7713
|
+
});
|
|
7714
|
+
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
7715
|
+
const pick = await page.evaluate(readContentRegion);
|
|
7716
|
+
return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
|
|
7717
|
+
} finally {
|
|
7718
|
+
await browser.close().catch(() => {
|
|
7719
|
+
});
|
|
7720
|
+
}
|
|
7721
|
+
}
|
|
7722
|
+
function readDom(marker) {
|
|
7723
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
7724
|
+
"nav",
|
|
7725
|
+
"aside",
|
|
7726
|
+
"footer",
|
|
7727
|
+
"script",
|
|
7728
|
+
"style",
|
|
7729
|
+
"noscript",
|
|
7730
|
+
"form",
|
|
7731
|
+
"svg",
|
|
7732
|
+
"canvas",
|
|
7733
|
+
"template",
|
|
7734
|
+
"button",
|
|
7735
|
+
"select",
|
|
7736
|
+
"textarea"
|
|
7737
|
+
]);
|
|
7738
|
+
const BLOCKY = /* @__PURE__ */ new Set([
|
|
7739
|
+
"p",
|
|
7740
|
+
"div",
|
|
7741
|
+
"section",
|
|
7742
|
+
"article",
|
|
7743
|
+
"main",
|
|
7744
|
+
"ul",
|
|
7745
|
+
"ol",
|
|
7746
|
+
"table",
|
|
7747
|
+
"figure",
|
|
7748
|
+
"blockquote",
|
|
7749
|
+
"pre",
|
|
7750
|
+
"h1",
|
|
7751
|
+
"h2",
|
|
7752
|
+
"h3",
|
|
7753
|
+
"h4",
|
|
7754
|
+
"h5",
|
|
7755
|
+
"h6"
|
|
7756
|
+
]);
|
|
7757
|
+
const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
|
|
7758
|
+
function words2(node) {
|
|
7759
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
7760
|
+
}
|
|
7761
|
+
function skipped(el) {
|
|
7762
|
+
return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
7763
|
+
}
|
|
7764
|
+
function pickSrc(el) {
|
|
7765
|
+
const candidates2 = (el.getAttribute("srcset") ?? "").split(",").map((part) => part.trim().split(/\s+/)).filter((bits) => (bits[0] ?? "") !== "").map((bits) => ({ url: bits[0] ?? "", w: Number.parseInt(bits[1] ?? "", 10) || 0 }));
|
|
7766
|
+
let widest2 = candidates2[0];
|
|
7767
|
+
for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
|
|
7768
|
+
return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
|
|
7769
|
+
}
|
|
7770
|
+
function imageOf(el, caption) {
|
|
7771
|
+
const src = pickSrc(el);
|
|
7772
|
+
if (!src) return void 0;
|
|
7773
|
+
return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
|
|
7774
|
+
}
|
|
7775
|
+
function videoOf(el, caption) {
|
|
7776
|
+
const source = el.querySelector("source");
|
|
7777
|
+
return {
|
|
7778
|
+
kind: "video",
|
|
7779
|
+
src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
|
|
7780
|
+
poster: el.getAttribute("poster") ?? "",
|
|
7781
|
+
href: "",
|
|
7782
|
+
caption
|
|
7783
|
+
};
|
|
7784
|
+
}
|
|
7785
|
+
function figureOf(el, out) {
|
|
7786
|
+
const caption = words2(el.querySelector("figcaption"));
|
|
7787
|
+
const video = el.querySelector("video");
|
|
7788
|
+
if (video) {
|
|
7789
|
+
out.push(videoOf(video, caption));
|
|
7790
|
+
return;
|
|
7791
|
+
}
|
|
7792
|
+
const image2 = el.querySelector("img");
|
|
7793
|
+
const block2 = image2 ? imageOf(image2, caption) : void 0;
|
|
7794
|
+
if (block2) {
|
|
7795
|
+
out.push(block2);
|
|
7796
|
+
return;
|
|
7797
|
+
}
|
|
7798
|
+
const text2 = words2(el);
|
|
7799
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
7800
|
+
}
|
|
7801
|
+
function paragraphOf(el, out) {
|
|
7802
|
+
const solid = [...el.childNodes].filter(
|
|
7803
|
+
(n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
|
|
7804
|
+
);
|
|
7805
|
+
const only = solid.length === 1 ? solid[0] : void 0;
|
|
7806
|
+
if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
|
|
7807
|
+
const href = only.getAttribute("href") ?? "";
|
|
7808
|
+
out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
|
|
7809
|
+
return;
|
|
7810
|
+
}
|
|
7811
|
+
let buffer = "";
|
|
7812
|
+
const flush = () => {
|
|
7813
|
+
const text2 = buffer.replace(/\s+/g, " ").trim();
|
|
7814
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
7815
|
+
buffer = "";
|
|
7816
|
+
};
|
|
7817
|
+
const scan = (node) => {
|
|
7818
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
7819
|
+
buffer += node.textContent ?? "";
|
|
7820
|
+
return;
|
|
7821
|
+
}
|
|
7822
|
+
if (!(node instanceof Element)) return;
|
|
7823
|
+
if (skipped(node)) return;
|
|
7824
|
+
const tag = node.tagName.toLowerCase();
|
|
7825
|
+
if (tag === "br") {
|
|
7826
|
+
buffer += " ";
|
|
7827
|
+
return;
|
|
7828
|
+
}
|
|
7829
|
+
if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
|
|
7830
|
+
flush();
|
|
7831
|
+
block(node, out);
|
|
7832
|
+
return;
|
|
7833
|
+
}
|
|
7834
|
+
for (const kid of node.childNodes) scan(kid);
|
|
7835
|
+
};
|
|
7836
|
+
for (const kid of el.childNodes) scan(kid);
|
|
7837
|
+
flush();
|
|
7838
|
+
}
|
|
7839
|
+
function listOf(el) {
|
|
7840
|
+
return {
|
|
7841
|
+
kind: "list",
|
|
7842
|
+
ordered: el.tagName === "OL",
|
|
7843
|
+
// A nested list flattens into its parent item, exactly as `blockText` in
|
|
7844
|
+
// src/source/markdown.ts already flattens one on the way back out.
|
|
7845
|
+
items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
|
|
7846
|
+
};
|
|
7847
|
+
}
|
|
7848
|
+
function tableOf(el) {
|
|
7849
|
+
const rows = [...el.querySelectorAll("tr")].map(
|
|
7850
|
+
(tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
|
|
7851
|
+
);
|
|
7852
|
+
const [head, ...body] = rows;
|
|
7853
|
+
return head ? { kind: "table", columns: head, rows: body } : void 0;
|
|
7854
|
+
}
|
|
7855
|
+
function block(el, out) {
|
|
7856
|
+
if (skipped(el)) return;
|
|
7857
|
+
const tag = el.tagName.toLowerCase();
|
|
7858
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
7859
|
+
const text2 = words2(el);
|
|
7860
|
+
if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
|
|
7861
|
+
return;
|
|
7862
|
+
}
|
|
7863
|
+
if (tag === "p" || tag === "blockquote") {
|
|
7864
|
+
paragraphOf(el, out);
|
|
7865
|
+
return;
|
|
7866
|
+
}
|
|
7867
|
+
if (tag === "ul" || tag === "ol") {
|
|
7868
|
+
const list = listOf(el);
|
|
7869
|
+
if (list.kind === "list" && list.items.length > 0) out.push(list);
|
|
7870
|
+
return;
|
|
7871
|
+
}
|
|
7872
|
+
if (tag === "table") {
|
|
7873
|
+
const table = tableOf(el);
|
|
7874
|
+
if (table) out.push(table);
|
|
7875
|
+
return;
|
|
7876
|
+
}
|
|
7877
|
+
if (tag === "figure") {
|
|
7878
|
+
figureOf(el, out);
|
|
7879
|
+
return;
|
|
7880
|
+
}
|
|
7881
|
+
if (tag === "img") {
|
|
7882
|
+
const image2 = imageOf(el, "");
|
|
7883
|
+
if (image2) out.push(image2);
|
|
7884
|
+
return;
|
|
7885
|
+
}
|
|
7886
|
+
if (tag === "video") {
|
|
7887
|
+
out.push(videoOf(el, ""));
|
|
7888
|
+
return;
|
|
7889
|
+
}
|
|
7890
|
+
if (tag === "iframe") {
|
|
7891
|
+
const href = el.getAttribute("src") ?? "";
|
|
7892
|
+
if (PLAYER.test(href)) {
|
|
7893
|
+
out.push({
|
|
7894
|
+
kind: "video",
|
|
7895
|
+
src: "",
|
|
7896
|
+
poster: "",
|
|
7897
|
+
href,
|
|
7898
|
+
caption: words2(el.getAttributeNode("title"))
|
|
7899
|
+
});
|
|
7900
|
+
}
|
|
7901
|
+
return;
|
|
7902
|
+
}
|
|
7903
|
+
if (tag === "pre") {
|
|
7904
|
+
const text2 = (el.textContent ?? "").replace(/\s+$/, "");
|
|
7905
|
+
if (text2) out.push({ kind: "code", text: text2 });
|
|
7906
|
+
return;
|
|
7907
|
+
}
|
|
7908
|
+
const direct = [...el.childNodes].some(
|
|
7909
|
+
(n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
|
|
7910
|
+
);
|
|
7911
|
+
if (direct) {
|
|
7912
|
+
paragraphOf(el, out);
|
|
7913
|
+
return;
|
|
7914
|
+
}
|
|
7915
|
+
walk(el, out);
|
|
7916
|
+
}
|
|
7917
|
+
function walk(el, out) {
|
|
7918
|
+
for (const kid of el.children) block(kid, out);
|
|
7919
|
+
}
|
|
7920
|
+
function score(el) {
|
|
7921
|
+
let text2 = 0;
|
|
7922
|
+
for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
|
|
7923
|
+
text2 += (p.textContent ?? "").trim().length;
|
|
7924
|
+
}
|
|
7925
|
+
let links = 0;
|
|
7926
|
+
for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
|
|
7927
|
+
return text2 - links;
|
|
7928
|
+
}
|
|
7929
|
+
function pickRoot() {
|
|
7930
|
+
const marked = document.querySelector(`[${marker}]`);
|
|
7931
|
+
if (marked) return marked;
|
|
7932
|
+
const main = document.querySelector("main");
|
|
7933
|
+
if (main && score(main) > 0) return main;
|
|
7934
|
+
const article = document.querySelector("article");
|
|
7935
|
+
if (article && score(article) > 0) return article;
|
|
7936
|
+
let best = document.body;
|
|
7937
|
+
let top = score(document.body);
|
|
7938
|
+
for (const el of document.body.querySelectorAll("div, section, td")) {
|
|
7939
|
+
const here = score(el);
|
|
7940
|
+
if (here >= top) {
|
|
7941
|
+
top = here;
|
|
7942
|
+
best = el;
|
|
7943
|
+
}
|
|
7944
|
+
}
|
|
7945
|
+
return best;
|
|
7946
|
+
}
|
|
7947
|
+
const root2 = pickRoot();
|
|
7948
|
+
const blocks = [];
|
|
7949
|
+
walk(root2, blocks);
|
|
7950
|
+
const heading = words2(root2.querySelector("h1")) || words2(document.querySelector("h1"));
|
|
7951
|
+
const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
|
|
7952
|
+
return {
|
|
7953
|
+
title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
|
|
7954
|
+
base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
|
|
7955
|
+
ogImage: og?.getAttribute("content") ?? "",
|
|
7956
|
+
blocks
|
|
7957
|
+
};
|
|
7958
|
+
}
|
|
7959
|
+
async function localise(seen, base, dir, opts, timeoutMs, warnings) {
|
|
7960
|
+
const maxAssets = opts.maxAssets ?? MAX_ASSETS;
|
|
7961
|
+
const maxClips = opts.maxClips ?? MAX_CLIPS;
|
|
7962
|
+
const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
7963
|
+
const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
|
|
7964
|
+
const deadline = Date.now() + wallMs;
|
|
7965
|
+
const out = [];
|
|
7966
|
+
const assets = [];
|
|
7967
|
+
const clips = [];
|
|
7968
|
+
let spent = 0;
|
|
7969
|
+
const done = /* @__PURE__ */ new Map();
|
|
7970
|
+
const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
|
|
7971
|
+
const overdrawn = () => {
|
|
7972
|
+
if (Date.now() >= deadline) {
|
|
7973
|
+
return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
|
|
7974
|
+
}
|
|
7975
|
+
if (spent >= maxBytes) {
|
|
7976
|
+
return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
|
|
7977
|
+
}
|
|
7978
|
+
return null;
|
|
7979
|
+
};
|
|
7980
|
+
const bytesOf = async (url) => {
|
|
7981
|
+
const got = await fetchGuarded(url, {
|
|
7982
|
+
maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
|
|
7983
|
+
timeoutMs,
|
|
7984
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
7985
|
+
});
|
|
7986
|
+
spent += got.bytes.length;
|
|
7987
|
+
return got.bytes;
|
|
7988
|
+
};
|
|
7989
|
+
const grab = async (raw2, what) => {
|
|
7990
|
+
const url = absolute(raw2, base);
|
|
7991
|
+
if (url === null) {
|
|
7992
|
+
warnings.push(
|
|
7993
|
+
`${what} was left out: "${raw2.slice(0, 120)}" is not an http(s) URL. Only web URLs are fetched; a data: or blob: source has to be saved by hand.`
|
|
7994
|
+
);
|
|
7995
|
+
return null;
|
|
7996
|
+
}
|
|
7997
|
+
const already = done.get(url);
|
|
7998
|
+
if (already !== void 0) return already;
|
|
7999
|
+
if (assets.length >= maxAssets) {
|
|
8000
|
+
done.set(url, null);
|
|
8001
|
+
warnings.push(
|
|
8002
|
+
`${what} was left out: ${url} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
|
|
8003
|
+
);
|
|
8004
|
+
return null;
|
|
8005
|
+
}
|
|
8006
|
+
const capped = overdrawn();
|
|
8007
|
+
if (capped !== null) {
|
|
8008
|
+
done.set(url, null);
|
|
8009
|
+
warnings.push(`${what} was left out: ${url} \u2014 ${capped} if the page is worth the wait.`);
|
|
8010
|
+
return null;
|
|
8011
|
+
}
|
|
8012
|
+
let got = null;
|
|
8013
|
+
try {
|
|
8014
|
+
const bytes = await bytesOf(url);
|
|
8015
|
+
const size = imageSize(bytes);
|
|
8016
|
+
if (size.width < MIN_FIGURE_PX || size.height < MIN_FIGURE_PX) {
|
|
8017
|
+
throw new Error(
|
|
8018
|
+
`it is ${size.width}x${size.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
|
|
8019
|
+
);
|
|
8020
|
+
}
|
|
8021
|
+
const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
|
|
8022
|
+
await writeFile3(path2, bytes);
|
|
8023
|
+
assets.push(path2);
|
|
8024
|
+
got = { path: path2, width: size.width, height: size.height };
|
|
8025
|
+
} catch (err) {
|
|
8026
|
+
got = null;
|
|
8027
|
+
warnings.push(`${what} was left out: ${url} \u2014 ${why(err)}`);
|
|
8028
|
+
}
|
|
8029
|
+
done.set(url, got);
|
|
8030
|
+
return got;
|
|
8031
|
+
};
|
|
8032
|
+
const shrink = async (url, path2, measured, what) => {
|
|
8033
|
+
if (opts.transcode === false) return { path: path2, ...measured };
|
|
8034
|
+
const out2 = join4(dir, assetName(url, ".vp9.webm"));
|
|
8035
|
+
let small;
|
|
8036
|
+
try {
|
|
8037
|
+
small = await transcode(path2, out2, {
|
|
8038
|
+
...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
|
|
8039
|
+
});
|
|
8040
|
+
} catch (err) {
|
|
8041
|
+
warnings.push(`${what} was shipped as the page served it: ${why(err)}`);
|
|
8042
|
+
return { path: path2, ...measured };
|
|
8043
|
+
}
|
|
8044
|
+
for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
|
|
8045
|
+
if (small.transcoded) {
|
|
8046
|
+
const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
|
|
8047
|
+
if (before > 0 && after > before) {
|
|
8048
|
+
warnings.push(
|
|
8049
|
+
`${what} \u2014 ${basename2(small.path)} came back LARGER than the page's own file (${mb(before)} \u2192 ${mb(after)}) at ${small.width}x${small.height}. The encode is sized for the render, which pre-decodes every clip to one still per output frame, so it still costs less to render; pass --no-transcode if the deck's size is what matters here.`
|
|
8050
|
+
);
|
|
8051
|
+
}
|
|
8052
|
+
await rm2(path2, { force: true });
|
|
8053
|
+
}
|
|
8054
|
+
return {
|
|
8055
|
+
path: small.path,
|
|
8056
|
+
width: small.width,
|
|
8057
|
+
height: small.height,
|
|
8058
|
+
...small.seconds === void 0 ? {} : { seconds: small.seconds }
|
|
8059
|
+
};
|
|
8060
|
+
};
|
|
8061
|
+
const grabVideo = async (url, what) => {
|
|
8062
|
+
if (clips.length >= maxClips) {
|
|
8063
|
+
warnings.push(
|
|
8064
|
+
maxClips === 0 ? `${what} is a link only: this harvest downloads no videos at all (maxClips is 0).` : `${what} is a link only: already holding ${maxClips} clips. Raise maxClips if the page really is that many videos.`
|
|
8065
|
+
);
|
|
8066
|
+
return null;
|
|
8067
|
+
}
|
|
8068
|
+
const capped = overdrawn();
|
|
8069
|
+
if (capped !== null) {
|
|
8070
|
+
warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
|
|
8071
|
+
return null;
|
|
8072
|
+
}
|
|
8073
|
+
try {
|
|
8074
|
+
const bytes = await bytesOf(url);
|
|
8075
|
+
const measured = videoSize(bytes);
|
|
8076
|
+
const path2 = join4(dir, assetName(url, `.${measured.container}`));
|
|
8077
|
+
await writeFile3(path2, bytes);
|
|
8078
|
+
return await shrink(url, path2, measured, what);
|
|
8079
|
+
} catch (err) {
|
|
8080
|
+
warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
|
|
8081
|
+
return null;
|
|
8082
|
+
}
|
|
8083
|
+
};
|
|
8084
|
+
let ogSpent = false;
|
|
8085
|
+
const stillFor = async (poster, named, needed) => {
|
|
8086
|
+
if (poster) return grab(poster, `the poster of ${named}`);
|
|
8087
|
+
if (needed && seen.ogImage && !ogSpent) {
|
|
8088
|
+
ogSpent = true;
|
|
8089
|
+
return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
|
|
8090
|
+
}
|
|
8091
|
+
return null;
|
|
8092
|
+
};
|
|
8093
|
+
for (const item of seen.blocks) {
|
|
8094
|
+
if (item.kind === "image") {
|
|
8095
|
+
const named = item.caption || item.alt;
|
|
8096
|
+
const got = await grab(item.src, named ? `the image "${named}"` : "an image");
|
|
8097
|
+
if (got) out.push({ ...item, src: ref(got) });
|
|
8098
|
+
continue;
|
|
8099
|
+
}
|
|
8100
|
+
if (item.kind === "video") {
|
|
8101
|
+
const named = item.caption ? `the video "${item.caption}"` : "a video";
|
|
8102
|
+
const file = absolute(item.src, base);
|
|
8103
|
+
const page = absolute(item.href, base);
|
|
8104
|
+
const subject = file ?? page;
|
|
8105
|
+
const watch = page ?? file;
|
|
8106
|
+
const policy = subject === null ? null : policyFor(subject, "bake");
|
|
8107
|
+
const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
|
|
8108
|
+
const still = await stillFor(item.poster, named, held === null);
|
|
8109
|
+
if (held !== null) {
|
|
8110
|
+
clips.push({
|
|
8111
|
+
file: held.path,
|
|
8112
|
+
poster: still?.path ?? "",
|
|
8113
|
+
href: "",
|
|
8114
|
+
width: held.width,
|
|
8115
|
+
height: held.height,
|
|
8116
|
+
...held.seconds === void 0 ? {} : { seconds: held.seconds },
|
|
8117
|
+
caption: item.caption
|
|
8118
|
+
});
|
|
8119
|
+
out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
|
|
8120
|
+
continue;
|
|
8121
|
+
}
|
|
8122
|
+
if (still !== null && watch !== null) {
|
|
8123
|
+
clips.push({
|
|
8124
|
+
file: "",
|
|
8125
|
+
poster: still.path,
|
|
8126
|
+
href: watch,
|
|
8127
|
+
width: still.width,
|
|
8128
|
+
height: still.height,
|
|
8129
|
+
caption: item.caption
|
|
8130
|
+
});
|
|
8131
|
+
warnings.push(
|
|
8132
|
+
`${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
|
|
8133
|
+
);
|
|
8134
|
+
out.push({ ...item, src: "", poster: ref(still), href: watch });
|
|
8135
|
+
continue;
|
|
8136
|
+
}
|
|
8137
|
+
if (still !== null) {
|
|
8138
|
+
warnings.push(
|
|
8139
|
+
`${named} is a still only: the page names no source for it, so there is nothing to play.`
|
|
8140
|
+
);
|
|
8141
|
+
out.push({ ...item, src: "", poster: ref(still), href: "" });
|
|
8142
|
+
continue;
|
|
8143
|
+
}
|
|
8144
|
+
if (watch === null) {
|
|
8145
|
+
warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
|
|
8146
|
+
continue;
|
|
8147
|
+
}
|
|
8148
|
+
warnings.push(
|
|
8149
|
+
`${named} is a link only and the page gave it no still: no poster attribute, no og:image left to spend. Save a frame by hand and add it to source.json as a clip figure.`
|
|
8150
|
+
);
|
|
8151
|
+
out.push({ ...item, src: "", poster: "", href: watch });
|
|
8152
|
+
continue;
|
|
8153
|
+
}
|
|
8154
|
+
out.push(item);
|
|
8155
|
+
}
|
|
8156
|
+
return { blocks: out, assets, clips };
|
|
8157
|
+
}
|
|
8158
|
+
function unheld(policy) {
|
|
8159
|
+
if (policy === "embed") return "it is a player page, which is never downloaded";
|
|
8160
|
+
if (policy === "link") {
|
|
8161
|
+
return "its URL does not end in a video extension, so what came back could as easily be a page";
|
|
8162
|
+
}
|
|
8163
|
+
return "the file could not be held";
|
|
8164
|
+
}
|
|
8165
|
+
async function sizeOf(path2) {
|
|
8166
|
+
return await stat(path2).then(
|
|
8167
|
+
(s) => s.size,
|
|
8168
|
+
() => 0
|
|
8169
|
+
);
|
|
8170
|
+
}
|
|
8171
|
+
function mb(bytes) {
|
|
8172
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
8173
|
+
}
|
|
8174
|
+
function absolute(raw2, base) {
|
|
8175
|
+
const trimmed = raw2.trim();
|
|
8176
|
+
if (!trimmed) return null;
|
|
8177
|
+
try {
|
|
8178
|
+
const url = new URL(trimmed, base);
|
|
8179
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
8180
|
+
} catch {
|
|
8181
|
+
return null;
|
|
8182
|
+
}
|
|
8183
|
+
}
|
|
8184
|
+
function assetName(url, ext) {
|
|
8185
|
+
const last = new URL(url).pathname.split("/").pop() ?? "";
|
|
8186
|
+
const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
|
|
8187
|
+
const hash = createHash5("sha256").update(url).digest("hex").slice(0, 8);
|
|
8188
|
+
return `${stem}-${hash}${ext}`;
|
|
8189
|
+
}
|
|
8190
|
+
function extFor2(format) {
|
|
8191
|
+
if (format === void 0) return ".img";
|
|
8192
|
+
return format === "jpeg" ? ".jpg" : `.${format}`;
|
|
8193
|
+
}
|
|
8194
|
+
function why(err) {
|
|
8195
|
+
return err instanceof Error ? err.message : String(err);
|
|
8196
|
+
}
|
|
8197
|
+
function sniffVideo(b) {
|
|
8198
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
|
|
8199
|
+
if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
|
|
8200
|
+
return void 0;
|
|
8201
|
+
}
|
|
8202
|
+
function videoSize(b) {
|
|
8203
|
+
const container = sniffVideo(b);
|
|
8204
|
+
if (container === void 0) {
|
|
8205
|
+
throw new Error(
|
|
8206
|
+
"its first bytes are neither an ISO base media (mp4) header nor an EBML (webm) one, so it is not a video this can measure \u2014 which is what an interstitial or a login page looks like"
|
|
8207
|
+
);
|
|
8208
|
+
}
|
|
8209
|
+
const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
|
|
8210
|
+
if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
|
|
8211
|
+
throw new Error(
|
|
8212
|
+
`it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
|
|
8213
|
+
);
|
|
8214
|
+
}
|
|
8215
|
+
return { ...measured, container };
|
|
8216
|
+
}
|
|
8217
|
+
function boxes2(b, from, to) {
|
|
8218
|
+
const out = [];
|
|
8219
|
+
let at = from;
|
|
8220
|
+
while (at + 8 <= to) {
|
|
8221
|
+
let size = b.readUInt32BE(at);
|
|
8222
|
+
const type = b.toString("latin1", at + 4, at + 8);
|
|
8223
|
+
let body = at + 8;
|
|
8224
|
+
if (size === 1) {
|
|
8225
|
+
if (body + 8 > to) return out;
|
|
8226
|
+
size = Number(b.readBigUInt64BE(body));
|
|
8227
|
+
body += 8;
|
|
8228
|
+
} else if (size === 0) {
|
|
8229
|
+
size = to - at;
|
|
8230
|
+
}
|
|
8231
|
+
const end = at + size;
|
|
8232
|
+
if (size < body - at || end > to) return out;
|
|
8233
|
+
out.push({ type, body, end });
|
|
8234
|
+
at = end;
|
|
8235
|
+
}
|
|
8236
|
+
return out;
|
|
8237
|
+
}
|
|
8238
|
+
function mp4Size(b) {
|
|
8239
|
+
const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
|
|
8240
|
+
if (moov === void 0) {
|
|
8241
|
+
throw new Error(
|
|
8242
|
+
"it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
|
|
8243
|
+
);
|
|
8244
|
+
}
|
|
8245
|
+
let seconds;
|
|
8246
|
+
let size;
|
|
8247
|
+
for (const box of boxes2(b, moov.body, moov.end)) {
|
|
8248
|
+
if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
|
|
8249
|
+
if (box.type !== "trak") continue;
|
|
8250
|
+
for (const inner of boxes2(b, box.body, box.end)) {
|
|
8251
|
+
if (inner.type === "tkhd" && size === void 0) size = tkhdSize(b, inner.body);
|
|
8252
|
+
}
|
|
8253
|
+
}
|
|
8254
|
+
if (size === void 0) {
|
|
8255
|
+
throw new Error(
|
|
8256
|
+
"no track in it declares a width and a height, so there is no picture to place"
|
|
8257
|
+
);
|
|
8258
|
+
}
|
|
8259
|
+
return { ...size, ...seconds === void 0 ? {} : { seconds } };
|
|
8260
|
+
}
|
|
8261
|
+
function tkhdSize(b, body) {
|
|
8262
|
+
const matrix = body + (b[body] === 1 ? 52 : 40);
|
|
8263
|
+
const at = matrix + 36;
|
|
8264
|
+
if (at + 8 > b.length) return void 0;
|
|
8265
|
+
const width = Math.round(b.readUInt32BE(at) / 65536);
|
|
8266
|
+
const height = Math.round(b.readUInt32BE(at + 4) / 65536);
|
|
8267
|
+
if (width <= 0 || height <= 0) return void 0;
|
|
8268
|
+
const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
|
|
8269
|
+
return quarterTurn ? { width: height, height: width } : { width, height };
|
|
8270
|
+
}
|
|
8271
|
+
function mvhdSeconds(b, body) {
|
|
8272
|
+
const long = b[body] === 1;
|
|
8273
|
+
const at = body + (long ? 20 : 12);
|
|
8274
|
+
if (at + (long ? 12 : 8) > b.length) return void 0;
|
|
8275
|
+
const timescale = b.readUInt32BE(at);
|
|
8276
|
+
const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
|
|
8277
|
+
if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
|
|
8278
|
+
return sane(ticks / timescale);
|
|
8279
|
+
}
|
|
8280
|
+
var EBML_SEGMENT = 408125543;
|
|
8281
|
+
var EBML_INFO = 357149030;
|
|
8282
|
+
var EBML_TRACKS = 374648427;
|
|
8283
|
+
var EBML_TRACK_ENTRY = 174;
|
|
8284
|
+
var EBML_VIDEO = 224;
|
|
8285
|
+
var EBML_PIXEL_WIDTH = 176;
|
|
8286
|
+
var EBML_PIXEL_HEIGHT = 186;
|
|
8287
|
+
var EBML_DISPLAY_WIDTH = 21680;
|
|
8288
|
+
var EBML_DISPLAY_HEIGHT = 21690;
|
|
8289
|
+
var EBML_TIMECODE_SCALE = 2807729;
|
|
8290
|
+
var EBML_DURATION = 17545;
|
|
8291
|
+
function webmSize(b) {
|
|
8292
|
+
let pixel = { width: 0, height: 0 };
|
|
8293
|
+
let display = { width: 0, height: 0 };
|
|
8294
|
+
let scale = 1e6;
|
|
8295
|
+
let ticks;
|
|
8296
|
+
const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
|
|
8297
|
+
const scan = (from, to, depth) => {
|
|
8298
|
+
let at = from;
|
|
8299
|
+
while (at < to) {
|
|
8300
|
+
const el = ebml(b, at, to);
|
|
8301
|
+
if (el === void 0 || el.end <= at) return;
|
|
8302
|
+
if (masters.has(el.id)) {
|
|
8303
|
+
if (depth < 6) scan(el.body, el.end, depth + 1);
|
|
8304
|
+
} else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
|
|
8305
|
+
pixel = { ...pixel, width: uint(b, el) };
|
|
8306
|
+
} else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
|
|
8307
|
+
pixel = { ...pixel, height: uint(b, el) };
|
|
8308
|
+
} else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
|
|
8309
|
+
display = { ...display, width: uint(b, el) };
|
|
8310
|
+
} else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
|
|
8311
|
+
display = { ...display, height: uint(b, el) };
|
|
8312
|
+
} else if (el.id === EBML_TIMECODE_SCALE) {
|
|
8313
|
+
scale = uint(b, el) || scale;
|
|
8314
|
+
} else if (el.id === EBML_DURATION) {
|
|
8315
|
+
ticks = float(b, el);
|
|
8316
|
+
}
|
|
8317
|
+
at = el.end;
|
|
8318
|
+
}
|
|
8319
|
+
};
|
|
8320
|
+
scan(0, b.length, 0);
|
|
8321
|
+
const width = display.width || pixel.width;
|
|
8322
|
+
const height = display.height || pixel.height;
|
|
8323
|
+
if (width <= 0 || height <= 0) {
|
|
8324
|
+
throw new Error(
|
|
8325
|
+
"no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
|
|
8326
|
+
);
|
|
8327
|
+
}
|
|
8328
|
+
const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
|
|
8329
|
+
return { width, height, ...seconds === void 0 ? {} : { seconds } };
|
|
8330
|
+
}
|
|
8331
|
+
function ebml(b, at, to) {
|
|
8332
|
+
const idLen = vintLen(b[at]);
|
|
8333
|
+
if (idLen === 0 || at + idLen > to) return void 0;
|
|
8334
|
+
let id2 = 0;
|
|
8335
|
+
for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
|
|
8336
|
+
let p = at + idLen;
|
|
8337
|
+
const sizeLen = vintLen(b[p]);
|
|
8338
|
+
if (sizeLen === 0 || p + sizeLen > to) return void 0;
|
|
8339
|
+
const first = b[p] ?? 0;
|
|
8340
|
+
const mask = 255 >> sizeLen;
|
|
8341
|
+
let size = first & mask;
|
|
8342
|
+
let unknown = size === mask;
|
|
8343
|
+
for (let i = 1; i < sizeLen; i += 1) {
|
|
8344
|
+
const byte = b[p + i] ?? 0;
|
|
8345
|
+
size = size * 256 + byte;
|
|
8346
|
+
unknown = unknown && byte === 255;
|
|
8347
|
+
}
|
|
8348
|
+
p += sizeLen;
|
|
8349
|
+
return { id: id2, body: p, end: unknown ? to : Math.min(p + size, to) };
|
|
8350
|
+
}
|
|
8351
|
+
function vintLen(first) {
|
|
8352
|
+
if (first === void 0 || first === 0) return 0;
|
|
8353
|
+
let len = 1;
|
|
8354
|
+
for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
|
|
8355
|
+
return len;
|
|
8356
|
+
}
|
|
8357
|
+
function uint(b, el) {
|
|
8358
|
+
let value = 0;
|
|
8359
|
+
for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
|
|
8360
|
+
value = value * 256 + (b[at] ?? 0);
|
|
8361
|
+
return value;
|
|
8362
|
+
}
|
|
8363
|
+
function float(b, el) {
|
|
8364
|
+
const width = el.end - el.body;
|
|
8365
|
+
if (width === 4) return b.readFloatBE(el.body);
|
|
8366
|
+
if (width === 8) return b.readDoubleBE(el.body);
|
|
8367
|
+
return void 0;
|
|
8368
|
+
}
|
|
8369
|
+
function sane(seconds) {
|
|
8370
|
+
return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
|
|
8371
|
+
}
|
|
8372
|
+
function toMarkdown(blocks) {
|
|
8373
|
+
const out = [];
|
|
8374
|
+
for (const item of blocks) {
|
|
8375
|
+
switch (item.kind) {
|
|
8376
|
+
case "heading":
|
|
8377
|
+
out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
|
|
8378
|
+
break;
|
|
8379
|
+
case "paragraph":
|
|
8380
|
+
out.push(inline(item.text));
|
|
8381
|
+
break;
|
|
8382
|
+
case "code":
|
|
8383
|
+
out.push(fenced(item.text));
|
|
8384
|
+
break;
|
|
8385
|
+
case "list":
|
|
8386
|
+
out.push(
|
|
8387
|
+
item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
|
|
8388
|
+
);
|
|
8389
|
+
break;
|
|
8390
|
+
case "table":
|
|
8391
|
+
out.push(pipes(item.columns, item.rows));
|
|
8392
|
+
break;
|
|
8393
|
+
case "image":
|
|
8394
|
+
out.push(`})`);
|
|
8395
|
+
if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
|
|
8396
|
+
break;
|
|
8397
|
+
case "video": {
|
|
8398
|
+
if (item.poster) {
|
|
8399
|
+
out.push(`})`);
|
|
8400
|
+
if (item.caption) out.push(`*${inline(item.caption)}*`);
|
|
8401
|
+
} else if (item.caption) {
|
|
8402
|
+
out.push(inline(item.caption));
|
|
8403
|
+
}
|
|
8404
|
+
const link = item.href || item.src;
|
|
8405
|
+
if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
|
|
8406
|
+
break;
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
8410
|
+
return `${out.filter((block) => block !== "").join("\n\n")}
|
|
8411
|
+
`;
|
|
8412
|
+
}
|
|
8413
|
+
function inline(text2) {
|
|
8414
|
+
return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
|
|
8415
|
+
}
|
|
8416
|
+
function destination(path2) {
|
|
8417
|
+
if (/[<>]/.test(path2)) {
|
|
8418
|
+
throw new Error(
|
|
8419
|
+
`cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
|
|
8420
|
+
);
|
|
8421
|
+
}
|
|
8422
|
+
return /[\s()]/.test(path2) ? `<${path2}>` : path2;
|
|
8423
|
+
}
|
|
8424
|
+
function pipes(columns2, rows) {
|
|
8425
|
+
const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
|
|
8426
|
+
const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
|
|
8427
|
+
const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
|
|
8428
|
+
return [cells(columns2), rule, ...rows.map(cells)].join("\n");
|
|
8429
|
+
}
|
|
8430
|
+
function fenced(code) {
|
|
8431
|
+
const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
|
|
8432
|
+
const fence = "`".repeat(Math.max(3, ...runs));
|
|
8433
|
+
return `${fence}
|
|
8434
|
+
${code}
|
|
8435
|
+
${fence}`;
|
|
8436
|
+
}
|
|
8437
|
+
|
|
8438
|
+
// src/plan/codex.ts
|
|
8439
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
8440
|
+
import { mkdtemp, readFile as readFile5, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
|
|
8441
|
+
import { tmpdir } from "node:os";
|
|
8442
|
+
import { join as join5 } from "node:path";
|
|
8443
|
+
import { z as z3 } from "zod";
|
|
8444
|
+
|
|
8445
|
+
// src/plan/arc.ts
|
|
8446
|
+
var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
|
|
8447
|
+
function requiredRoles(beatCount) {
|
|
8448
|
+
if (beatCount >= 8) return ARC_ROLES;
|
|
8449
|
+
if (beatCount >= 5) return ["limitations", "conclusion"];
|
|
8450
|
+
return [];
|
|
8451
|
+
}
|
|
8452
|
+
function paperArcRequested(prefs) {
|
|
8453
|
+
return prefs.genre === "paper";
|
|
8454
|
+
}
|
|
8455
|
+
|
|
8456
|
+
// src/plan/duration.ts
|
|
8457
|
+
var SPEECH_CPS = { latin: 14.4, cjk: 6.5 };
|
|
8458
|
+
var LAST_HOLD_SECONDS = 4.2;
|
|
6192
8459
|
var STOPS_PER_BEAT = 3.1;
|
|
6193
8460
|
var MOTION_SHARE = 0.35;
|
|
6194
8461
|
var MIN_SENTENCE_CHARS = 30;
|
|
@@ -6927,9 +9194,28 @@ function renderSource(source) {
|
|
|
6927
9194
|
} else {
|
|
6928
9195
|
const n3 = source.figures.length;
|
|
6929
9196
|
out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
|
|
9197
|
+
if (source.figures.some((f) => f.kind === "clip")) {
|
|
9198
|
+
out.push(
|
|
9199
|
+
"",
|
|
9200
|
+
"A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
|
|
9201
|
+
"figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
|
|
9202
|
+
"same reason: it is the picture that carries the point.",
|
|
9203
|
+
"WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
|
|
9204
|
+
"holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
|
|
9205
|
+
"they press play, and the rendered video shows that same frame unless the clip is",
|
|
9206
|
+
"a file this deck actually holds. A clip listed as watchable only at a link is a",
|
|
9207
|
+
"page whose video we could not download, and its still is all any format will ever",
|
|
9208
|
+
"show. So write the beat for the FRAME: claim what the picture states, and never",
|
|
9209
|
+
"narrate a motion the still does not."
|
|
9210
|
+
);
|
|
9211
|
+
}
|
|
6930
9212
|
const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
|
|
6931
9213
|
for (const f of source.figures) {
|
|
6932
|
-
|
|
9214
|
+
const size = f.kind === "clip" ? `CLIP ${f.width}x${f.height}${f.seconds === void 0 ? "" : `, ${Math.round(f.seconds * 10) / 10}s`}` : `${f.width}x${f.height}`;
|
|
9215
|
+
out.push("", `[figure ${f.id}] ${size} \u2014 ${f.caption}`);
|
|
9216
|
+
if (f.kind === "clip" && f.href) {
|
|
9217
|
+
out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
|
|
9218
|
+
}
|
|
6933
9219
|
const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
|
|
6934
9220
|
if (f.sectionId !== void 0) {
|
|
6935
9221
|
out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
|
|
@@ -7063,7 +9349,7 @@ function assertRefsResolve(storyboard, source, opts = {}) {
|
|
|
7063
9349
|
}
|
|
7064
9350
|
|
|
7065
9351
|
// src/plan/codex.ts
|
|
7066
|
-
var
|
|
9352
|
+
var DEFAULT_TIMEOUT_MS2 = 10 * 6e4;
|
|
7067
9353
|
var UNSUPPORTED = /* @__PURE__ */ new Set([
|
|
7068
9354
|
"$schema",
|
|
7069
9355
|
"default",
|
|
@@ -7138,26 +9424,26 @@ function hideFromPlanner(node, hidden) {
|
|
|
7138
9424
|
}
|
|
7139
9425
|
function schemaFor(prefs) {
|
|
7140
9426
|
return hideFromPlanner(
|
|
7141
|
-
forStructuredOutput(
|
|
9427
|
+
forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
|
|
7142
9428
|
plannerInvisible(prefs)
|
|
7143
9429
|
);
|
|
7144
9430
|
}
|
|
7145
9431
|
var SCHEMA = schemaFor({ genre: "general" });
|
|
7146
9432
|
async function codexPlanner(source, opts = {}) {
|
|
7147
9433
|
const prefs = opts.prefs ?? prefsSchema.parse({});
|
|
7148
|
-
const dir = await mkdtemp(
|
|
9434
|
+
const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
|
|
7149
9435
|
try {
|
|
7150
|
-
const schemaPath =
|
|
7151
|
-
const outPath =
|
|
7152
|
-
await
|
|
9436
|
+
const schemaPath = join5(dir, "storyboard.schema.json");
|
|
9437
|
+
const outPath = join5(dir, "storyboard.json");
|
|
9438
|
+
await writeFile4(schemaPath, JSON.stringify(schemaFor(prefs)));
|
|
7153
9439
|
await (opts.run ?? runCodex)({
|
|
7154
9440
|
prompt: buildPrompt(source, prefs),
|
|
7155
9441
|
schemaPath,
|
|
7156
9442
|
outPath,
|
|
7157
9443
|
...opts.model === void 0 ? {} : { model: opts.model },
|
|
7158
|
-
timeoutMs: opts.timeoutMs ??
|
|
9444
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
|
|
7159
9445
|
});
|
|
7160
|
-
const raw2 = await
|
|
9446
|
+
const raw2 = await readFile5(outPath, "utf8").catch(() => "");
|
|
7161
9447
|
if (raw2.trim() === "") {
|
|
7162
9448
|
throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
|
|
7163
9449
|
}
|
|
@@ -7188,7 +9474,7 @@ ${issues}`);
|
|
|
7188
9474
|
assertRefsResolve(result.data, source, { pending: prefs.images.enabled ? "allow" : "refuse" });
|
|
7189
9475
|
return result.data;
|
|
7190
9476
|
} finally {
|
|
7191
|
-
await
|
|
9477
|
+
await rm3(dir, { recursive: true, force: true });
|
|
7192
9478
|
}
|
|
7193
9479
|
}
|
|
7194
9480
|
function buildPrompt(source, prefs) {
|
|
@@ -7230,8 +9516,8 @@ function codexCommand(args) {
|
|
|
7230
9516
|
}
|
|
7231
9517
|
function runCodex(args) {
|
|
7232
9518
|
const { argv, env: env2 } = codexCommand(args);
|
|
7233
|
-
return new Promise((
|
|
7234
|
-
const child =
|
|
9519
|
+
return new Promise((resolve7, reject) => {
|
|
9520
|
+
const child = spawn2("codex", argv, {
|
|
7235
9521
|
stdio: ["pipe", "ignore", "pipe"],
|
|
7236
9522
|
...env2 === void 0 ? {} : { env: env2 }
|
|
7237
9523
|
});
|
|
@@ -7253,7 +9539,7 @@ function runCodex(args) {
|
|
|
7253
9539
|
});
|
|
7254
9540
|
child.on("close", (code) => {
|
|
7255
9541
|
clearTimeout(timer);
|
|
7256
|
-
if (code === 0) return
|
|
9542
|
+
if (code === 0) return resolve7();
|
|
7257
9543
|
reject(
|
|
7258
9544
|
new Error(`codex exec exited ${code}.
|
|
7259
9545
|
${stderr.trim().split("\n").slice(-8).join("\n")}`)
|
|
@@ -7264,16 +9550,16 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
|
|
|
7264
9550
|
}
|
|
7265
9551
|
|
|
7266
9552
|
// src/images/illustrate.ts
|
|
7267
|
-
import { createHash as
|
|
7268
|
-
import { mkdir as
|
|
7269
|
-
import { join as
|
|
9553
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
9554
|
+
import { mkdir as mkdir4, readFile as readFile7, rename, rm as rm5, writeFile as writeFile6 } from "node:fs/promises";
|
|
9555
|
+
import { join as join7 } from "node:path";
|
|
7270
9556
|
|
|
7271
9557
|
// src/images/providers.ts
|
|
7272
|
-
import { createHash as
|
|
7273
|
-
import { mkdtemp as mkdtemp2, readFile as
|
|
9558
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
9559
|
+
import { mkdtemp as mkdtemp2, readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
|
|
7274
9560
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
7275
|
-
import { join as
|
|
7276
|
-
import { z as
|
|
9561
|
+
import { join as join6, resolve as resolve2 } from "node:path";
|
|
9562
|
+
import { z as z4 } from "zod";
|
|
7277
9563
|
var SIZE = {
|
|
7278
9564
|
landscape: { width: 1536, height: 1024 },
|
|
7279
9565
|
square: { width: 1024, height: 1024 },
|
|
@@ -7296,11 +9582,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
|
7296
9582
|
var DEFAULT_MODEL = "gpt-image-2";
|
|
7297
9583
|
var REQUEST_TIMEOUT_MS = 12e4;
|
|
7298
9584
|
var MAX_BYTES = 16 * 1024 * 1024;
|
|
7299
|
-
var generatedSchema =
|
|
7300
|
-
data:
|
|
9585
|
+
var generatedSchema = z4.object({
|
|
9586
|
+
data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
|
|
7301
9587
|
});
|
|
7302
|
-
var failedSchema =
|
|
7303
|
-
error:
|
|
9588
|
+
var failedSchema = z4.object({
|
|
9589
|
+
error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
|
|
7304
9590
|
});
|
|
7305
9591
|
function openaiImages(opts) {
|
|
7306
9592
|
const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
@@ -7325,8 +9611,8 @@ function openaiImages(opts) {
|
|
|
7325
9611
|
}),
|
|
7326
9612
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7327
9613
|
});
|
|
7328
|
-
const body = await
|
|
7329
|
-
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${
|
|
9614
|
+
const body = await readCapped2(res);
|
|
9615
|
+
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
|
|
7330
9616
|
const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
|
|
7331
9617
|
if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
|
|
7332
9618
|
if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
|
|
@@ -7339,14 +9625,14 @@ function openaiImages(opts) {
|
|
|
7339
9625
|
redirect: "error",
|
|
7340
9626
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7341
9627
|
});
|
|
7342
|
-
const bytes = await
|
|
9628
|
+
const bytes = await readCapped2(picture);
|
|
7343
9629
|
if (!picture.ok)
|
|
7344
9630
|
throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
|
|
7345
9631
|
return raster(bytes, "openai images");
|
|
7346
9632
|
}
|
|
7347
9633
|
};
|
|
7348
9634
|
}
|
|
7349
|
-
function
|
|
9635
|
+
function reason2(body) {
|
|
7350
9636
|
const error = failedSchema.safeParse(parseJson(body)).data?.error;
|
|
7351
9637
|
const code = error?.code ?? error?.type;
|
|
7352
9638
|
return code ? ` (${code})` : "";
|
|
@@ -7358,7 +9644,7 @@ function parseJson(body) {
|
|
|
7358
9644
|
return void 0;
|
|
7359
9645
|
}
|
|
7360
9646
|
}
|
|
7361
|
-
async function
|
|
9647
|
+
async function readCapped2(res) {
|
|
7362
9648
|
const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
|
|
7363
9649
|
if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
|
|
7364
9650
|
const reader = res.body?.getReader();
|
|
@@ -7388,10 +9674,10 @@ var ANSWER_SCHEMA = {
|
|
|
7388
9674
|
required: ["ok", "file", "reason"],
|
|
7389
9675
|
additionalProperties: false
|
|
7390
9676
|
};
|
|
7391
|
-
var answerSchema =
|
|
7392
|
-
ok:
|
|
7393
|
-
file:
|
|
7394
|
-
reason:
|
|
9677
|
+
var answerSchema = z4.object({
|
|
9678
|
+
ok: z4.boolean(),
|
|
9679
|
+
file: z4.string().nullish(),
|
|
9680
|
+
reason: z4.string().nullish()
|
|
7395
9681
|
});
|
|
7396
9682
|
function codexImages(opts = {}) {
|
|
7397
9683
|
const run5 = opts.run ?? runCodex;
|
|
@@ -7404,11 +9690,11 @@ function codexImages(opts = {}) {
|
|
|
7404
9690
|
async check() {
|
|
7405
9691
|
},
|
|
7406
9692
|
async generate(req) {
|
|
7407
|
-
const dir = await mkdtemp2(
|
|
9693
|
+
const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
|
|
7408
9694
|
try {
|
|
7409
|
-
const schemaPath =
|
|
7410
|
-
const outPath =
|
|
7411
|
-
await
|
|
9695
|
+
const schemaPath = join6(dir, "answer.schema.json");
|
|
9696
|
+
const outPath = join6(dir, "answer.json");
|
|
9697
|
+
await writeFile5(schemaPath, JSON.stringify(ANSWER_SCHEMA));
|
|
7412
9698
|
await run5({
|
|
7413
9699
|
prompt: codexPrompt(req),
|
|
7414
9700
|
schemaPath,
|
|
@@ -7417,7 +9703,7 @@ function codexImages(opts = {}) {
|
|
|
7417
9703
|
cwd: dir,
|
|
7418
9704
|
sandbox: "workspace-write"
|
|
7419
9705
|
});
|
|
7420
|
-
const raw2 = await
|
|
9706
|
+
const raw2 = await readFile6(outPath, "utf8").catch(() => "");
|
|
7421
9707
|
const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
|
|
7422
9708
|
if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
|
|
7423
9709
|
if (!answer.data.ok) {
|
|
@@ -7425,15 +9711,15 @@ function codexImages(opts = {}) {
|
|
|
7425
9711
|
`codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
|
|
7426
9712
|
);
|
|
7427
9713
|
}
|
|
7428
|
-
const candidates2 = [
|
|
7429
|
-
if (answer.data.file) candidates2.push(
|
|
9714
|
+
const candidates2 = [join6(dir, "picture.png")];
|
|
9715
|
+
if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
|
|
7430
9716
|
for (const path2 of candidates2) {
|
|
7431
|
-
const bytes = await
|
|
9717
|
+
const bytes = await readFile6(path2).catch(() => null);
|
|
7432
9718
|
if (bytes) return raster(bytes, "codex");
|
|
7433
9719
|
}
|
|
7434
9720
|
throw new Error("codex said ok but wrote no picture.png");
|
|
7435
9721
|
} finally {
|
|
7436
|
-
await
|
|
9722
|
+
await rm4(dir, { recursive: true, force: true });
|
|
7437
9723
|
}
|
|
7438
9724
|
}
|
|
7439
9725
|
};
|
|
@@ -7468,7 +9754,7 @@ function mulberry32(seed) {
|
|
|
7468
9754
|
}
|
|
7469
9755
|
function drawSvg(req) {
|
|
7470
9756
|
const { width: W, height: H } = SIZE[req.aspect];
|
|
7471
|
-
const seed =
|
|
9757
|
+
const seed = createHash6("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
|
|
7472
9758
|
const rand = mulberry32(seed.readUInt32BE(0));
|
|
7473
9759
|
const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
|
|
7474
9760
|
const count = 6 + Math.floor(rand() * 5);
|
|
@@ -7499,11 +9785,6 @@ function drawSvg(req) {
|
|
|
7499
9785
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
|
|
7500
9786
|
`;
|
|
7501
9787
|
}
|
|
7502
|
-
function svgSize(bytes) {
|
|
7503
|
-
const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
|
|
7504
|
-
if (!m) throw new Error("svg has no viewBox");
|
|
7505
|
-
return { width: Number(m[1]), height: Number(m[2]) };
|
|
7506
|
-
}
|
|
7507
9788
|
function toolSvg() {
|
|
7508
9789
|
return {
|
|
7509
9790
|
id: "svg",
|
|
@@ -7547,7 +9828,7 @@ function imageChain(images, backend) {
|
|
|
7547
9828
|
}
|
|
7548
9829
|
|
|
7549
9830
|
// src/images/illustrate.ts
|
|
7550
|
-
var
|
|
9831
|
+
var EXT2 = {
|
|
7551
9832
|
"image/png": ".png",
|
|
7552
9833
|
"image/jpeg": ".jpg",
|
|
7553
9834
|
"image/svg+xml": ".svg"
|
|
@@ -7565,7 +9846,7 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7565
9846
|
const tool = last?.id === "svg" ? last : toolSvg();
|
|
7566
9847
|
if (tool !== last) chain.push(tool);
|
|
7567
9848
|
const dropped = /* @__PURE__ */ new Set();
|
|
7568
|
-
if (pending.length > 0) await
|
|
9849
|
+
if (pending.length > 0) await mkdir4(opts.assetsDir, { recursive: true });
|
|
7569
9850
|
for (const [i, slot] of pending.entries()) {
|
|
7570
9851
|
let rungs = chain;
|
|
7571
9852
|
if (i >= images.max) {
|
|
@@ -7585,6 +9866,9 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7585
9866
|
const picture = await draw(provider, req, name, opts.assetsDir);
|
|
7586
9867
|
const figure = {
|
|
7587
9868
|
id: slot.figureId,
|
|
9869
|
+
// An illustration is always a still: every provider draws a picture,
|
|
9870
|
+
// and there is no rung that returns a video.
|
|
9871
|
+
kind: "image",
|
|
7588
9872
|
src: picture.src,
|
|
7589
9873
|
caption: slot.brief.caption,
|
|
7590
9874
|
width: picture.width,
|
|
@@ -7603,8 +9887,8 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7603
9887
|
} catch (err) {
|
|
7604
9888
|
const after = live[j + 1];
|
|
7605
9889
|
if (!after) throw err;
|
|
7606
|
-
const
|
|
7607
|
-
step(`illustrate: ${slot.label} via ${provider.id} failed (${
|
|
9890
|
+
const why2 = err instanceof Error ? err.message : String(err);
|
|
9891
|
+
step(`illustrate: ${slot.label} via ${provider.id} failed (${why2}); trying ${after.id}`);
|
|
7608
9892
|
dropped.add(provider.id);
|
|
7609
9893
|
}
|
|
7610
9894
|
}
|
|
@@ -7654,36 +9938,36 @@ function slots(storyboard, known) {
|
|
|
7654
9938
|
return out;
|
|
7655
9939
|
}
|
|
7656
9940
|
function cacheKey(providerId, req) {
|
|
7657
|
-
return
|
|
9941
|
+
return createHash7("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
|
|
7658
9942
|
}
|
|
7659
9943
|
async function draw(provider, req, name, dir) {
|
|
7660
|
-
for (const ext of Object.values(
|
|
9944
|
+
for (const ext of Object.values(EXT2)) {
|
|
7661
9945
|
const src2 = `${name}${ext}`;
|
|
7662
|
-
const bytes = await
|
|
7663
|
-
if (bytes) return { src: src2, ...
|
|
9946
|
+
const bytes = await readFile7(join7(dir, src2)).catch(() => null);
|
|
9947
|
+
if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
|
|
7664
9948
|
}
|
|
7665
9949
|
await provider.check();
|
|
7666
9950
|
const img = await provider.generate(req);
|
|
7667
|
-
const src = `${name}${
|
|
7668
|
-
const tmp =
|
|
9951
|
+
const src = `${name}${EXT2[img.mime]}`;
|
|
9952
|
+
const tmp = join7(dir, `.${name}.tmp`);
|
|
7669
9953
|
try {
|
|
7670
|
-
await
|
|
7671
|
-
await rename(tmp,
|
|
9954
|
+
await writeFile6(tmp, img.bytes);
|
|
9955
|
+
await rename(tmp, join7(dir, src));
|
|
7672
9956
|
} finally {
|
|
7673
|
-
await
|
|
9957
|
+
await rm5(tmp, { force: true });
|
|
7674
9958
|
}
|
|
7675
9959
|
return { src, width: img.width, height: img.height, cached: false };
|
|
7676
9960
|
}
|
|
7677
|
-
function
|
|
9961
|
+
function sizeOf2(bytes, ext) {
|
|
7678
9962
|
return ext === ".svg" ? svgSize(bytes) : imageSize(bytes);
|
|
7679
9963
|
}
|
|
7680
9964
|
|
|
7681
9965
|
// src/narrate/tts.ts
|
|
7682
|
-
import { spawn as
|
|
7683
|
-
import { createHash as
|
|
7684
|
-
import { mkdir as
|
|
7685
|
-
import { homedir } from "node:os";
|
|
7686
|
-
import { join as
|
|
9966
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
9967
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
9968
|
+
import { mkdir as mkdir5, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "node:fs/promises";
|
|
9969
|
+
import { homedir as homedir2 } from "node:os";
|
|
9970
|
+
import { join as join8 } from "node:path";
|
|
7687
9971
|
var MISSING = [
|
|
7688
9972
|
"edge-tts is not installed, so narration cannot be synthesised.",
|
|
7689
9973
|
"",
|
|
@@ -7694,14 +9978,14 @@ var MISSING = [
|
|
|
7694
9978
|
].join("\n");
|
|
7695
9979
|
function candidates() {
|
|
7696
9980
|
const env2 = process.env.DECKSMITH_EDGE_TTS?.trim();
|
|
7697
|
-
const home =
|
|
9981
|
+
const home = homedir2();
|
|
7698
9982
|
return [
|
|
7699
9983
|
...env2 ? [[env2]] : [],
|
|
7700
9984
|
["edge-tts"],
|
|
7701
9985
|
// pip --user on macOS and on Linux respectively. Neither is on PATH by
|
|
7702
9986
|
// default, and both are where this actually lands in practice.
|
|
7703
|
-
[
|
|
7704
|
-
[
|
|
9987
|
+
[join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
|
|
9988
|
+
[join8(home, ".local", "bin", "edge-tts")],
|
|
7705
9989
|
// Last resort: the module is installed even though its console script is
|
|
7706
9990
|
// nowhere findable, which is the normal state of a pip --user install.
|
|
7707
9991
|
["python3", "-m", "edge_tts"]
|
|
@@ -7715,19 +9999,19 @@ async function answersHelp(argv) {
|
|
|
7715
9999
|
}
|
|
7716
10000
|
var resolved = null;
|
|
7717
10001
|
function resolveEdgeTts(can = answersHelp) {
|
|
7718
|
-
if (can !== answersHelp) return
|
|
7719
|
-
resolved ??=
|
|
10002
|
+
if (can !== answersHelp) return find2(can);
|
|
10003
|
+
resolved ??= find2(can);
|
|
7720
10004
|
return resolved;
|
|
7721
10005
|
}
|
|
7722
|
-
async function
|
|
10006
|
+
async function find2(can) {
|
|
7723
10007
|
for (const argv of candidates()) {
|
|
7724
10008
|
if (await can(argv)) return argv;
|
|
7725
10009
|
}
|
|
7726
10010
|
throw new Error(MISSING);
|
|
7727
10011
|
}
|
|
7728
10012
|
function runArgv(cmd, args) {
|
|
7729
|
-
return new Promise((
|
|
7730
|
-
const child =
|
|
10013
|
+
return new Promise((resolve7) => {
|
|
10014
|
+
const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
7731
10015
|
let stdout = "";
|
|
7732
10016
|
let stderr = "";
|
|
7733
10017
|
child.stdout.on("data", (b) => {
|
|
@@ -7736,8 +10020,8 @@ function runArgv(cmd, args) {
|
|
|
7736
10020
|
child.stderr.on("data", (b) => {
|
|
7737
10021
|
stderr += b.toString();
|
|
7738
10022
|
});
|
|
7739
|
-
child.on("error", (e) =>
|
|
7740
|
-
child.on("close", (code) =>
|
|
10023
|
+
child.on("error", (e) => resolve7({ code: -1, stderr: String(e), stdout }));
|
|
10024
|
+
child.on("close", (code) => resolve7({ code: code ?? -1, stderr, stdout }));
|
|
7741
10025
|
});
|
|
7742
10026
|
}
|
|
7743
10027
|
var edgeTts = {
|
|
@@ -7804,8 +10088,8 @@ function edgeProvider(runner = edgeTts) {
|
|
|
7804
10088
|
async speak(req) {
|
|
7805
10089
|
const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
|
|
7806
10090
|
await runner.speak({ ...req, subs });
|
|
7807
|
-
const raw2 = await
|
|
7808
|
-
await
|
|
10091
|
+
const raw2 = await readFile8(subs, "utf8").catch(() => "");
|
|
10092
|
+
await rm6(subs, { force: true });
|
|
7809
10093
|
const cues = parseCues(raw2);
|
|
7810
10094
|
const measured = await runner.measure(req.audio);
|
|
7811
10095
|
const lastCue = cues.length > 0 ? cues[cues.length - 1].end : 0;
|
|
@@ -7814,7 +10098,7 @@ function edgeProvider(runner = edgeTts) {
|
|
|
7814
10098
|
};
|
|
7815
10099
|
}
|
|
7816
10100
|
function cacheKey2(text2, voice, rate, pitch) {
|
|
7817
|
-
return
|
|
10101
|
+
return createHash8("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
|
|
7818
10102
|
}
|
|
7819
10103
|
async function synthesize(text2, opts) {
|
|
7820
10104
|
const rate = opts.rate ?? "+0%";
|
|
@@ -7822,11 +10106,11 @@ async function synthesize(text2, opts) {
|
|
|
7822
10106
|
const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
|
|
7823
10107
|
const key = cacheKey2(text2, opts.voice, rate, pitch);
|
|
7824
10108
|
const file = `${key}.mp3`;
|
|
7825
|
-
const audio =
|
|
7826
|
-
const sidecar =
|
|
10109
|
+
const audio = join8(opts.dir, file);
|
|
10110
|
+
const sidecar = join8(opts.dir, `${key}.json`);
|
|
7827
10111
|
const cached = await readSidecar(sidecar);
|
|
7828
10112
|
if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
|
|
7829
|
-
await
|
|
10113
|
+
await mkdir5(opts.dir, { recursive: true });
|
|
7830
10114
|
const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
|
|
7831
10115
|
const { cues, seconds } = spoken;
|
|
7832
10116
|
const clamped = cues.map((c) => ({
|
|
@@ -7835,13 +10119,13 @@ async function synthesize(text2, opts) {
|
|
|
7835
10119
|
text: c.text
|
|
7836
10120
|
}));
|
|
7837
10121
|
const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
|
|
7838
|
-
await
|
|
10122
|
+
await writeFile7(sidecar, `${JSON.stringify(body, null, 2)}
|
|
7839
10123
|
`);
|
|
7840
10124
|
return { audio, file, seconds, cues: clamped };
|
|
7841
10125
|
}
|
|
7842
10126
|
async function readSidecar(path2) {
|
|
7843
10127
|
try {
|
|
7844
|
-
const parsed = JSON.parse(await
|
|
10128
|
+
const parsed = JSON.parse(await readFile8(path2, "utf8"));
|
|
7845
10129
|
const s = parsed;
|
|
7846
10130
|
return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
|
|
7847
10131
|
} catch {
|
|
@@ -7894,7 +10178,7 @@ function stopCount(holds) {
|
|
|
7894
10178
|
return Math.max(1, usable.size);
|
|
7895
10179
|
}
|
|
7896
10180
|
function stopsFor(beat, source, format, sid = "s1") {
|
|
7897
|
-
const ctx = { source, format, theme: ink, sid };
|
|
10181
|
+
const ctx = { source, format, theme: ink, sid, start: 0 };
|
|
7898
10182
|
try {
|
|
7899
10183
|
return stopCount(emitScene(beat, ctx).holds);
|
|
7900
10184
|
} catch {
|
|
@@ -7933,7 +10217,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7933
10217
|
for (const [i, beat] of storyboard.beats.entries()) {
|
|
7934
10218
|
const text2 = beat.narration?.trim();
|
|
7935
10219
|
if (!text2) continue;
|
|
7936
|
-
const
|
|
10220
|
+
const segments2 = [];
|
|
7937
10221
|
const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
|
|
7938
10222
|
const plan = planSegments(text2, stops);
|
|
7939
10223
|
for (const [stop, line2] of plan.entries()) {
|
|
@@ -7945,7 +10229,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7945
10229
|
dir: opts.dir,
|
|
7946
10230
|
runner: opts.runner
|
|
7947
10231
|
});
|
|
7948
|
-
|
|
10232
|
+
segments2.push({
|
|
7949
10233
|
stop,
|
|
7950
10234
|
text: line2,
|
|
7951
10235
|
// Content-addressed and flat, so the path is the filename and the deck
|
|
@@ -7955,37 +10239,21 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7955
10239
|
cues: speech.cues
|
|
7956
10240
|
});
|
|
7957
10241
|
}
|
|
7958
|
-
if (
|
|
10242
|
+
if (segments2.length > 0) beats[beat.id] = segments2;
|
|
7959
10243
|
}
|
|
7960
10244
|
return { voice, beats };
|
|
7961
10245
|
}
|
|
7962
10246
|
|
|
7963
10247
|
// src/verify/check.ts
|
|
7964
|
-
import { execFile } from "node:child_process";
|
|
7965
|
-
import { promisify } from "node:util";
|
|
7966
|
-
var run = promisify(execFile);
|
|
7967
|
-
|
|
7968
|
-
// src/render/capture.ts
|
|
7969
|
-
import { homedir as homedir2 } from "node:os";
|
|
7970
|
-
import { join as join7 } from "node:path";
|
|
7971
|
-
async function chromePath(need = "open the deck with") {
|
|
7972
|
-
const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
|
|
7973
|
-
if (explicit) return explicit;
|
|
7974
|
-
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
7975
|
-
const cacheDir = process.env.PUPPETEER_CACHE_DIR || join7(homedir2(), ".cache", "puppeteer");
|
|
7976
|
-
const installed = await getInstalledBrowsers({ cacheDir }).catch(() => []);
|
|
7977
|
-
const found = installed.find((b) => b.browser === "chrome-headless-shell") ?? installed.find((b) => b.browser === "chrome");
|
|
7978
|
-
if (found) return found.executablePath;
|
|
7979
|
-
throw new Error(
|
|
7980
|
-
`no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
|
|
7981
|
-
);
|
|
7982
|
-
}
|
|
7983
|
-
|
|
7984
|
-
// src/verify/drift.ts
|
|
7985
10248
|
import { execFile as execFile2 } from "node:child_process";
|
|
7986
10249
|
import { promisify as promisify2 } from "node:util";
|
|
7987
10250
|
var run2 = promisify2(execFile2);
|
|
7988
10251
|
|
|
10252
|
+
// src/verify/drift.ts
|
|
10253
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
10254
|
+
import { promisify as promisify3 } from "node:util";
|
|
10255
|
+
var run3 = promisify3(execFile3);
|
|
10256
|
+
|
|
7989
10257
|
// src/verify/index.ts
|
|
7990
10258
|
function scanBeatCount(storyboard, prefs) {
|
|
7991
10259
|
const got = storyboard.beats.length;
|
|
@@ -8004,12 +10272,12 @@ function scanBeatCount(storyboard, prefs) {
|
|
|
8004
10272
|
}
|
|
8005
10273
|
|
|
8006
10274
|
// src/render/render.ts
|
|
8007
|
-
import { mkdir as
|
|
8008
|
-
import { basename, dirname, join as
|
|
10275
|
+
import { mkdir as mkdir7, readFile as readFile9, rename as rename2, rm as rm7, writeFile as writeFile9 } from "node:fs/promises";
|
|
10276
|
+
import { basename as basename3, dirname, join as join10, resolve as resolve3 } from "node:path";
|
|
8009
10277
|
|
|
8010
10278
|
// src/render/captions.ts
|
|
8011
|
-
import { mkdir as
|
|
8012
|
-
import { join as
|
|
10279
|
+
import { mkdir as mkdir6, writeFile as writeFile8 } from "node:fs/promises";
|
|
10280
|
+
import { join as join9 } from "node:path";
|
|
8013
10281
|
import { pathToFileURL } from "node:url";
|
|
8014
10282
|
var DECK_FONT_CSS = "assets/fonts/fonts.css";
|
|
8015
10283
|
function cssString(value) {
|
|
@@ -8065,7 +10333,7 @@ ${bands}
|
|
|
8065
10333
|
</div>
|
|
8066
10334
|
`;
|
|
8067
10335
|
}
|
|
8068
|
-
function
|
|
10336
|
+
function measure3(count) {
|
|
8069
10337
|
const doc = document;
|
|
8070
10338
|
const probe3 = doc.getElementById("c0");
|
|
8071
10339
|
if (probe3) {
|
|
@@ -8093,247 +10361,81 @@ async function captionBlocker() {
|
|
|
8093
10361
|
try {
|
|
8094
10362
|
await import("puppeteer-core");
|
|
8095
10363
|
await chromePath(CAPTION_NEED);
|
|
8096
|
-
return null;
|
|
8097
|
-
} catch (err) {
|
|
8098
|
-
return err instanceof Error ? err.message : String(err);
|
|
8099
|
-
}
|
|
8100
|
-
}
|
|
8101
|
-
async function renderCaptions(cues, style, deck, work2) {
|
|
8102
|
-
if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
|
|
8103
|
-
const fontCss = join8(deck, DECK_FONT_CSS);
|
|
8104
|
-
const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL(fontCss).href : null;
|
|
8105
|
-
const page = join8(work2, "captions.html");
|
|
8106
|
-
await mkdir5(work2, { recursive: true });
|
|
8107
|
-
await writeFile7(page, captionPage(cues, style, href));
|
|
8108
|
-
const { default: puppeteer } = await import("puppeteer-core");
|
|
8109
|
-
const browser = await puppeteer.launch({
|
|
8110
|
-
executablePath: await chromePath(CAPTION_NEED),
|
|
8111
|
-
headless: true,
|
|
8112
|
-
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
8113
|
-
});
|
|
8114
|
-
try {
|
|
8115
|
-
const tab = await browser.newPage();
|
|
8116
|
-
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
8117
|
-
await tab.goto(pathToFileURL(page).href, { waitUntil: "load" });
|
|
8118
|
-
await tab.evaluate(() => document.fonts.ready);
|
|
8119
|
-
const rects = await tab.evaluate(measure2, cues.length);
|
|
8120
|
-
const box = union(rects);
|
|
8121
|
-
const files = [];
|
|
8122
|
-
for (const [i, cue] of cues.entries()) {
|
|
8123
|
-
void cue;
|
|
8124
|
-
const name = `cap${String(i).padStart(4, "0")}.png`;
|
|
8125
|
-
await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
|
|
8126
|
-
await tab.screenshot({
|
|
8127
|
-
path: join8(work2, name),
|
|
8128
|
-
type: "png",
|
|
8129
|
-
omitBackground: true,
|
|
8130
|
-
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
8131
|
-
});
|
|
8132
|
-
await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
|
|
8133
|
-
files.push(name);
|
|
8134
|
-
}
|
|
8135
|
-
return { files, ...box };
|
|
8136
|
-
} finally {
|
|
8137
|
-
await browser.close();
|
|
8138
|
-
}
|
|
8139
|
-
}
|
|
8140
|
-
function union(rects) {
|
|
8141
|
-
const left = Math.floor(Math.min(...rects.map((r) => r.x)));
|
|
8142
|
-
const top = Math.floor(Math.min(...rects.map((r) => r.y)));
|
|
8143
|
-
const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
|
|
8144
|
-
const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
|
|
8145
|
-
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
8146
|
-
}
|
|
8147
|
-
var t3 = (seconds) => seconds.toFixed(3);
|
|
8148
|
-
function overlayGraph(cues, band, input = "0:v", output = "vout") {
|
|
8149
|
-
return cues.map((cue, i) => {
|
|
8150
|
-
const from = i === 0 ? `[${input}]` : `[v${i}]`;
|
|
8151
|
-
const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
|
|
8152
|
-
const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
|
|
8153
|
-
return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
|
|
8154
|
-
}).join(";\n");
|
|
8155
|
-
}
|
|
8156
|
-
function overlayInputs(band) {
|
|
8157
|
-
return band.files.flatMap((file) => ["-i", file]);
|
|
8158
|
-
}
|
|
8159
|
-
|
|
8160
|
-
// src/render/ffmpeg.ts
|
|
8161
|
-
import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
|
|
8162
|
-
import { promisify as promisify3 } from "node:util";
|
|
8163
|
-
var run3 = promisify3(execFile3);
|
|
8164
|
-
var DEFAULT_TIMEOUT_MS2 = 36e5;
|
|
8165
|
-
async function runTool(file, args, opts = {}) {
|
|
8166
|
-
try {
|
|
8167
|
-
return await run3(file, args, {
|
|
8168
|
-
cwd: opts.cwd,
|
|
8169
|
-
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
|
|
8170
|
-
maxBuffer: 64 << 20
|
|
8171
|
-
});
|
|
8172
|
-
} catch (err) {
|
|
8173
|
-
const e = err;
|
|
8174
|
-
if (e.code === "ENOENT") {
|
|
8175
|
-
throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
|
|
8176
|
-
}
|
|
8177
|
-
const tail = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
|
|
8178
|
-
throw new Error(`${file} failed:
|
|
8179
|
-
${tail}`);
|
|
8180
|
-
}
|
|
8181
|
-
}
|
|
8182
|
-
function runLive(file, args) {
|
|
8183
|
-
return new Promise((resolve6, reject) => {
|
|
8184
|
-
const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
8185
|
-
child.on("error", (err) => {
|
|
8186
|
-
reject(
|
|
8187
|
-
err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
|
|
8188
|
-
);
|
|
8189
|
-
});
|
|
8190
|
-
child.on("close", (code, signal) => {
|
|
8191
|
-
if (code === 0) resolve6();
|
|
8192
|
-
else if (signal) {
|
|
8193
|
-
reject(
|
|
8194
|
-
new Error(
|
|
8195
|
-
`${file} was killed by ${signal}. On a machine under memory pressure this is the OS reclaiming the browser; free memory, or lower --workers, and run again.`
|
|
8196
|
-
)
|
|
8197
|
-
);
|
|
8198
|
-
} else reject(new Error(`${file} exited ${code}.`));
|
|
8199
|
-
});
|
|
8200
|
-
});
|
|
8201
|
-
}
|
|
8202
|
-
async function probe(path2) {
|
|
8203
|
-
const { stdout } = await runTool("ffprobe", [
|
|
8204
|
-
"-v",
|
|
8205
|
-
"error",
|
|
8206
|
-
"-show_entries",
|
|
8207
|
-
"stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
|
|
8208
|
-
"-of",
|
|
8209
|
-
"json",
|
|
8210
|
-
path2
|
|
8211
|
-
]);
|
|
8212
|
-
const json = JSON.parse(stdout);
|
|
8213
|
-
const streams = json.streams ?? [];
|
|
8214
|
-
const video = streams.find((s) => s.codec_type === "video");
|
|
8215
|
-
if (!video) throw new Error(`${path2} has no video stream.`);
|
|
8216
|
-
const [num2, den] = (video.r_frame_rate ?? "30/1").split("/");
|
|
8217
|
-
const fps = Number(num2) / (Number(den) || 1);
|
|
8218
|
-
const seconds = Number(json.format?.duration ?? 0);
|
|
8219
|
-
const frames = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
|
|
8220
|
-
return {
|
|
8221
|
-
width: video.width ?? 0,
|
|
8222
|
-
height: video.height ?? 0,
|
|
8223
|
-
fps,
|
|
8224
|
-
frames,
|
|
8225
|
-
seconds,
|
|
8226
|
-
hasAudio: streams.some((s) => s.codec_type === "audio")
|
|
8227
|
-
};
|
|
8228
|
-
}
|
|
8229
|
-
function encoderArgs(fps) {
|
|
8230
|
-
return [
|
|
8231
|
-
"-c:v",
|
|
8232
|
-
"libx264",
|
|
8233
|
-
"-preset",
|
|
8234
|
-
"veryfast",
|
|
8235
|
-
"-crf",
|
|
8236
|
-
"16",
|
|
8237
|
-
"-pix_fmt",
|
|
8238
|
-
"yuv420p",
|
|
8239
|
-
"-g",
|
|
8240
|
-
"12",
|
|
8241
|
-
"-r",
|
|
8242
|
-
String(fps),
|
|
8243
|
-
"-fps_mode",
|
|
8244
|
-
"cfr"
|
|
8245
|
-
];
|
|
8246
|
-
}
|
|
8247
|
-
function pieceFilter(motion, freeze) {
|
|
8248
|
-
const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
|
|
8249
|
-
if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
|
|
8250
|
-
return chain.join(",");
|
|
8251
|
-
}
|
|
8252
|
-
function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
|
|
8253
|
-
return [
|
|
8254
|
-
"-y",
|
|
8255
|
-
"-hide_banner",
|
|
8256
|
-
"-loglevel",
|
|
8257
|
-
"error",
|
|
8258
|
-
// Half a frame in, so a time that is exactly on a boundary cannot round to
|
|
8259
|
-
// the frame before it.
|
|
8260
|
-
"-ss",
|
|
8261
|
-
((fromFrame + 0.5) / fps).toFixed(6),
|
|
8262
|
-
"-i",
|
|
8263
|
-
source,
|
|
8264
|
-
"-an",
|
|
8265
|
-
"-vf",
|
|
8266
|
-
pieceFilter(motion, freeze),
|
|
8267
|
-
"-frames:v",
|
|
8268
|
-
String(motion + freeze),
|
|
8269
|
-
...encoderArgs(fps),
|
|
8270
|
-
"-f",
|
|
8271
|
-
"mpegts",
|
|
8272
|
-
out
|
|
8273
|
-
];
|
|
10364
|
+
return null;
|
|
10365
|
+
} catch (err) {
|
|
10366
|
+
return err instanceof Error ? err.message : String(err);
|
|
10367
|
+
}
|
|
8274
10368
|
}
|
|
8275
|
-
|
|
8276
|
-
|
|
8277
|
-
const
|
|
8278
|
-
|
|
8279
|
-
);
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
10369
|
+
async function renderCaptions(cues, style, deck, work2) {
|
|
10370
|
+
if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
|
|
10371
|
+
const fontCss = join9(deck, DECK_FONT_CSS);
|
|
10372
|
+
const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL(fontCss).href : null;
|
|
10373
|
+
const page = join9(work2, "captions.html");
|
|
10374
|
+
await mkdir6(work2, { recursive: true });
|
|
10375
|
+
await writeFile8(page, captionPage(cues, style, href));
|
|
10376
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
10377
|
+
const browser = await puppeteer.launch({
|
|
10378
|
+
executablePath: await chromePath(CAPTION_NEED),
|
|
10379
|
+
headless: true,
|
|
10380
|
+
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
10381
|
+
});
|
|
10382
|
+
try {
|
|
10383
|
+
const tab = await browser.newPage();
|
|
10384
|
+
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
10385
|
+
await tab.goto(pathToFileURL(page).href, { waitUntil: "load" });
|
|
10386
|
+
await tab.evaluate(() => document.fonts.ready);
|
|
10387
|
+
const rects = await tab.evaluate(measure3, cues.length);
|
|
10388
|
+
const box = union(rects);
|
|
10389
|
+
const files = [];
|
|
10390
|
+
for (const [i, cue] of cues.entries()) {
|
|
10391
|
+
void cue;
|
|
10392
|
+
const name = `cap${String(i).padStart(4, "0")}.png`;
|
|
10393
|
+
await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
|
|
10394
|
+
await tab.screenshot({
|
|
10395
|
+
path: join9(work2, name),
|
|
10396
|
+
type: "png",
|
|
10397
|
+
omitBackground: true,
|
|
10398
|
+
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
10399
|
+
});
|
|
10400
|
+
await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
|
|
10401
|
+
files.push(name);
|
|
10402
|
+
}
|
|
10403
|
+
return { files, ...box };
|
|
10404
|
+
} finally {
|
|
10405
|
+
await browser.close();
|
|
10406
|
+
}
|
|
8285
10407
|
}
|
|
8286
|
-
function
|
|
8287
|
-
const
|
|
8288
|
-
const
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8292
|
-
"-loglevel",
|
|
8293
|
-
"error",
|
|
8294
|
-
"-i",
|
|
8295
|
-
source,
|
|
8296
|
-
"-filter_complex",
|
|
8297
|
-
`${video}${audio}`,
|
|
8298
|
-
"-map",
|
|
8299
|
-
"[v]",
|
|
8300
|
-
...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
|
|
8301
|
-
...encoderArgs(fps),
|
|
8302
|
-
out
|
|
8303
|
-
];
|
|
10408
|
+
function union(rects) {
|
|
10409
|
+
const left = Math.floor(Math.min(...rects.map((r) => r.x)));
|
|
10410
|
+
const top = Math.floor(Math.min(...rects.map((r) => r.y)));
|
|
10411
|
+
const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
|
|
10412
|
+
const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
|
|
10413
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
8304
10414
|
}
|
|
8305
|
-
|
|
8306
|
-
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
// "4% of the width" looked like on paper — overflows to a THIRD line, and a
|
|
8317
|
-
// three-line band covers the bottom of the slide.
|
|
8318
|
-
fontSize: Math.round(width * 0.037),
|
|
8319
|
-
// Clear of the play button, the progress bar and the handle every vertical
|
|
8320
|
-
// player draws across the bottom eighth of the frame.
|
|
8321
|
-
marginV: Math.round(height * 0.09),
|
|
8322
|
-
marginX: Math.round(width * 0.04)
|
|
8323
|
-
};
|
|
10415
|
+
var t3 = (seconds) => seconds.toFixed(3);
|
|
10416
|
+
function overlayGraph(cues, band, input = "0:v", output = "vout") {
|
|
10417
|
+
return cues.map((cue, i) => {
|
|
10418
|
+
const from = i === 0 ? `[${input}]` : `[v${i}]`;
|
|
10419
|
+
const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
|
|
10420
|
+
const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
|
|
10421
|
+
return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
|
|
10422
|
+
}).join(";\n");
|
|
10423
|
+
}
|
|
10424
|
+
function overlayInputs(band) {
|
|
10425
|
+
return band.files.flatMap((file) => ["-i", file]);
|
|
8324
10426
|
}
|
|
8325
10427
|
|
|
8326
10428
|
// src/render/render.ts
|
|
8327
|
-
var SRT_NAME = (out) => `${
|
|
10429
|
+
var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
|
|
8328
10430
|
function subtitlePlan(mode) {
|
|
8329
10431
|
return { sidecar: mode !== "none", burn: mode === "burn" };
|
|
8330
10432
|
}
|
|
8331
10433
|
async function render(opts) {
|
|
8332
10434
|
const log = opts.log ?? (() => {
|
|
8333
10435
|
});
|
|
8334
|
-
const deck =
|
|
8335
|
-
const out =
|
|
8336
|
-
const work2 =
|
|
10436
|
+
const deck = resolve3(opts.deck);
|
|
10437
|
+
const out = resolve3(opts.out);
|
|
10438
|
+
const work2 = join10(dirname(out), `.${basename3(out)}.parts`);
|
|
8337
10439
|
const timing = await readTiming(deck);
|
|
8338
10440
|
if (opts.targetSeconds && !opts.allowFastPlayback) {
|
|
8339
10441
|
const refusal = playbackRefusal(
|
|
@@ -8349,9 +10451,9 @@ async function render(opts) {
|
|
|
8349
10451
|
if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
|
|
8350
10452
|
}
|
|
8351
10453
|
const burnable = plan0.burn;
|
|
8352
|
-
await
|
|
10454
|
+
await mkdir7(work2, { recursive: true });
|
|
8353
10455
|
try {
|
|
8354
|
-
const raw2 = opts.video ?
|
|
10456
|
+
const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join10(work2, "raw.mp4"), opts, log);
|
|
8355
10457
|
const shot = await probe(raw2);
|
|
8356
10458
|
log(
|
|
8357
10459
|
`render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
|
|
@@ -8364,18 +10466,18 @@ async function render(opts) {
|
|
|
8364
10466
|
);
|
|
8365
10467
|
}
|
|
8366
10468
|
const retimed = await retime(raw2, plan, work2, log);
|
|
8367
|
-
const srtPath =
|
|
10469
|
+
const srtPath = join10(dirname(out), SRT_NAME(out));
|
|
8368
10470
|
const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
|
|
8369
10471
|
const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
|
|
8370
10472
|
const srt = plan0.sidecar ? toSrt(cues) : "";
|
|
8371
|
-
if (srt) await
|
|
10473
|
+
if (srt) await writeFile9(srtPath, srt);
|
|
8372
10474
|
const burn = burnable && srt.length > 0;
|
|
8373
10475
|
await mux(retimed, timing, plan, deck, out, work2, burn ? plan.cues : void 0, shot.fps, log);
|
|
8374
10476
|
if (playback > 1) {
|
|
8375
10477
|
log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
|
|
8376
10478
|
const warning = playbackWarning(playback, p95CueRate(plan.cues));
|
|
8377
10479
|
if (warning) log(`render: ${warning}`);
|
|
8378
|
-
const fast =
|
|
10480
|
+
const fast = join10(work2, `fast.${basename3(out)}`);
|
|
8379
10481
|
const muxed = await probe(out);
|
|
8380
10482
|
await runTool(
|
|
8381
10483
|
"ffmpeg",
|
|
@@ -8401,12 +10503,12 @@ async function render(opts) {
|
|
|
8401
10503
|
captionCps: p95CueRate(cues)
|
|
8402
10504
|
};
|
|
8403
10505
|
} finally {
|
|
8404
|
-
if (!opts.keep) await
|
|
10506
|
+
if (!opts.keep) await rm7(work2, { recursive: true, force: true });
|
|
8405
10507
|
}
|
|
8406
10508
|
}
|
|
8407
10509
|
async function readTiming(deck) {
|
|
8408
|
-
const path2 =
|
|
8409
|
-
const text2 = await
|
|
10510
|
+
const path2 = join10(deck, TIMING_FILE);
|
|
10511
|
+
const text2 = await readFile9(path2, "utf8").catch(() => {
|
|
8410
10512
|
throw new Error(
|
|
8411
10513
|
`${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
|
|
8412
10514
|
);
|
|
@@ -8445,20 +10547,20 @@ async function retime(raw2, plan, work2, log) {
|
|
|
8445
10547
|
if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
|
|
8446
10548
|
const list = [];
|
|
8447
10549
|
for (const [i, piece] of plan.pieces.entries()) {
|
|
8448
|
-
const file =
|
|
10550
|
+
const file = join10(work2, `p${String(i).padStart(4, "0")}.ts`);
|
|
8449
10551
|
await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
|
|
8450
10552
|
list.push(file);
|
|
8451
10553
|
if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
|
|
8452
10554
|
log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
|
|
8453
10555
|
}
|
|
8454
10556
|
}
|
|
8455
|
-
const listFile =
|
|
8456
|
-
await
|
|
10557
|
+
const listFile = join10(work2, "pieces.txt");
|
|
10558
|
+
await writeFile9(
|
|
8457
10559
|
listFile,
|
|
8458
10560
|
`${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
|
|
8459
10561
|
`
|
|
8460
10562
|
);
|
|
8461
|
-
const joined =
|
|
10563
|
+
const joined = join10(work2, "retimed.mp4");
|
|
8462
10564
|
await runTool("ffmpeg", [
|
|
8463
10565
|
"-y",
|
|
8464
10566
|
"-hide_banner",
|
|
@@ -8480,7 +10582,7 @@ async function retime(raw2, plan, work2, log) {
|
|
|
8480
10582
|
}
|
|
8481
10583
|
async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
|
|
8482
10584
|
const inputs = plan.audio.map((a) => ({
|
|
8483
|
-
file:
|
|
10585
|
+
file: join10(deck, timing.audioDir, a.audio),
|
|
8484
10586
|
delayMs: a.delayMs
|
|
8485
10587
|
}));
|
|
8486
10588
|
const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
|
|
@@ -8501,8 +10603,8 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
|
|
|
8501
10603
|
graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
|
|
8502
10604
|
}
|
|
8503
10605
|
if (graph.length > 0) {
|
|
8504
|
-
const script =
|
|
8505
|
-
await
|
|
10606
|
+
const script = join10(work2, "mux.filter");
|
|
10607
|
+
await writeFile9(script, `${graph.join(";\n")}
|
|
8506
10608
|
`);
|
|
8507
10609
|
args.push("-filter_complex_script", script);
|
|
8508
10610
|
}
|
|
@@ -8517,13 +10619,13 @@ async function mux(video, timing, plan, deck, out, work2, burnCues, fps, log) {
|
|
|
8517
10619
|
log(
|
|
8518
10620
|
`render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
|
|
8519
10621
|
);
|
|
8520
|
-
await
|
|
10622
|
+
await mkdir7(dirname(out), { recursive: true });
|
|
8521
10623
|
await runTool("ffmpeg", args, { cwd: work2 });
|
|
8522
10624
|
}
|
|
8523
10625
|
|
|
8524
10626
|
// src/pack/pack.ts
|
|
8525
|
-
import { mkdir as
|
|
8526
|
-
import { dirname as dirname2, extname as
|
|
10627
|
+
import { mkdir as mkdir8, readFile as readFile10, writeFile as writeFile10 } from "node:fs/promises";
|
|
10628
|
+
import { dirname as dirname2, extname as extname3 } from "node:path";
|
|
8527
10629
|
import { unzipSync, zipSync } from "fflate";
|
|
8528
10630
|
var MTIME = Date.UTC(1980, 0, 2, 12);
|
|
8529
10631
|
var STORED = /* @__PURE__ */ new Set([
|
|
@@ -8559,11 +10661,11 @@ async function writePack(pack3, files, out) {
|
|
|
8559
10661
|
if (!bytes) continue;
|
|
8560
10662
|
if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
|
|
8561
10663
|
check2(path2);
|
|
8562
|
-
entries[path2] = STORED.has(
|
|
10664
|
+
entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
|
|
8563
10665
|
}
|
|
8564
10666
|
const zip = zipSync(entries, { mtime: MTIME });
|
|
8565
|
-
await
|
|
8566
|
-
await
|
|
10667
|
+
await mkdir8(dirname2(out), { recursive: true });
|
|
10668
|
+
await writeFile10(out, zip);
|
|
8567
10669
|
return zip.length;
|
|
8568
10670
|
}
|
|
8569
10671
|
function check2(label, path2 = label) {
|
|
@@ -8571,137 +10673,6 @@ function check2(label, path2 = label) {
|
|
|
8571
10673
|
throw new Error(`${label}: unsafe path in pack`);
|
|
8572
10674
|
}
|
|
8573
10675
|
|
|
8574
|
-
// src/pack/media.ts
|
|
8575
|
-
import { createHash as createHash7 } from "node:crypto";
|
|
8576
|
-
import { readFile as readFile9 } from "node:fs/promises";
|
|
8577
|
-
import { extname as extname3 } from "node:path";
|
|
8578
|
-
var PLAYERS = [
|
|
8579
|
-
"youtube.com",
|
|
8580
|
-
"youtube-nocookie.com",
|
|
8581
|
-
"youtu.be",
|
|
8582
|
-
"vimeo.com",
|
|
8583
|
-
"dailymotion.com",
|
|
8584
|
-
"dai.ly",
|
|
8585
|
-
"twitch.tv",
|
|
8586
|
-
"loom.com",
|
|
8587
|
-
"wistia.com",
|
|
8588
|
-
"wistia.net",
|
|
8589
|
-
"streamable.com",
|
|
8590
|
-
"bilibili.com",
|
|
8591
|
-
"soundcloud.com",
|
|
8592
|
-
"tiktok.com"
|
|
8593
|
-
];
|
|
8594
|
-
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
8595
|
-
".png",
|
|
8596
|
-
".jpg",
|
|
8597
|
-
".jpeg",
|
|
8598
|
-
".gif",
|
|
8599
|
-
".webp",
|
|
8600
|
-
".avif",
|
|
8601
|
-
".svg",
|
|
8602
|
-
".mp4",
|
|
8603
|
-
".webm",
|
|
8604
|
-
".mov",
|
|
8605
|
-
".m4v",
|
|
8606
|
-
".mp3",
|
|
8607
|
-
".m4a",
|
|
8608
|
-
".wav",
|
|
8609
|
-
".ogg",
|
|
8610
|
-
".opus",
|
|
8611
|
-
".pdf"
|
|
8612
|
-
]);
|
|
8613
|
-
function isEmbed(url) {
|
|
8614
|
-
const host2 = hostOf(url);
|
|
8615
|
-
return host2 !== null && PLAYERS.some((p) => host2 === p || host2.endsWith(`.${p}`));
|
|
8616
|
-
}
|
|
8617
|
-
function policyFor(url, prefer) {
|
|
8618
|
-
if (isEmbed(url)) return "embed";
|
|
8619
|
-
const host2 = hostOf(url);
|
|
8620
|
-
if (host2 === null || /^data:/i.test(url)) return "bake";
|
|
8621
|
-
if (prefer === "link") return "link";
|
|
8622
|
-
return FILE_EXT.has(extOf(url)) ? "bake" : "link";
|
|
8623
|
-
}
|
|
8624
|
-
async function planMedia(assets, fetcher = fetchAsset) {
|
|
8625
|
-
const plan = {
|
|
8626
|
-
media: [],
|
|
8627
|
-
files: {},
|
|
8628
|
-
bakedCount: 0,
|
|
8629
|
-
bakedBytes: 0,
|
|
8630
|
-
demoted: [],
|
|
8631
|
-
promoted: []
|
|
8632
|
-
};
|
|
8633
|
-
for (const asset of assets) {
|
|
8634
|
-
const policy = policyFor(asset.url, asset.prefer);
|
|
8635
|
-
if (policy !== "bake") {
|
|
8636
|
-
if (asset.prefer === "bake") plan.demoted.push(asset.id);
|
|
8637
|
-
plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
|
|
8638
|
-
continue;
|
|
8639
|
-
}
|
|
8640
|
-
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
8641
|
-
const got = await fetcher(asset.url);
|
|
8642
|
-
const mime = clean(got.mime) ?? asset.mime;
|
|
8643
|
-
if (mime === "text/html") {
|
|
8644
|
-
plan.demoted.push(asset.id);
|
|
8645
|
-
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
8646
|
-
continue;
|
|
8647
|
-
}
|
|
8648
|
-
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
8649
|
-
plan.files[path2] = got.bytes;
|
|
8650
|
-
plan.bakedCount += 1;
|
|
8651
|
-
plan.bakedBytes += got.bytes.length;
|
|
8652
|
-
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
8653
|
-
}
|
|
8654
|
-
return plan;
|
|
8655
|
-
}
|
|
8656
|
-
function bakedName(id2, url, mime) {
|
|
8657
|
-
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
8658
|
-
const hash = createHash7("sha256").update(url).digest("hex").slice(0, 8);
|
|
8659
|
-
return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
|
|
8660
|
-
}
|
|
8661
|
-
function extOf(url) {
|
|
8662
|
-
const path2 = hostOf(url) === null ? url : new URL(url).pathname;
|
|
8663
|
-
const ext = extname3(path2).toLowerCase();
|
|
8664
|
-
return FILE_EXT.has(ext) ? ext : "";
|
|
8665
|
-
}
|
|
8666
|
-
var MIME_EXT = {
|
|
8667
|
-
"image/png": ".png",
|
|
8668
|
-
"image/jpeg": ".jpg",
|
|
8669
|
-
"image/gif": ".gif",
|
|
8670
|
-
"image/webp": ".webp",
|
|
8671
|
-
"image/avif": ".avif",
|
|
8672
|
-
"image/svg+xml": ".svg",
|
|
8673
|
-
"video/mp4": ".mp4",
|
|
8674
|
-
"video/webm": ".webm",
|
|
8675
|
-
"audio/mpeg": ".mp3",
|
|
8676
|
-
"application/pdf": ".pdf"
|
|
8677
|
-
};
|
|
8678
|
-
function extFor(mime) {
|
|
8679
|
-
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
8680
|
-
}
|
|
8681
|
-
function hostOf(url) {
|
|
8682
|
-
try {
|
|
8683
|
-
const u = new URL(url);
|
|
8684
|
-
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
8685
|
-
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
8686
|
-
} catch {
|
|
8687
|
-
return null;
|
|
8688
|
-
}
|
|
8689
|
-
}
|
|
8690
|
-
function clean(mime) {
|
|
8691
|
-
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
8692
|
-
}
|
|
8693
|
-
async function fetchAsset(url) {
|
|
8694
|
-
if (/^(https?|data):/i.test(url)) {
|
|
8695
|
-
const res = await fetch(url);
|
|
8696
|
-
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
8697
|
-
return {
|
|
8698
|
-
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
8699
|
-
mime: res.headers.get("content-type") ?? void 0
|
|
8700
|
-
};
|
|
8701
|
-
}
|
|
8702
|
-
return { bytes: new Uint8Array(await readFile9(url)) };
|
|
8703
|
-
}
|
|
8704
|
-
|
|
8705
10676
|
// src/prefs.ts
|
|
8706
10677
|
var PREF_KEYS = Object.keys(prefsSchema.shape);
|
|
8707
10678
|
var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
|
|
@@ -8713,8 +10684,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
8713
10684
|
if (!format) throw new Error("no deck-16x9 format");
|
|
8714
10685
|
const step = opts.onStep ?? (() => {
|
|
8715
10686
|
});
|
|
8716
|
-
const out =
|
|
8717
|
-
await
|
|
10687
|
+
const out = resolve4(outDir);
|
|
10688
|
+
await mkdir9(out, { recursive: true });
|
|
8718
10689
|
const speed = opts.speed ?? 1;
|
|
8719
10690
|
const fontCss = await refreshFont(storyboard, source, out, step);
|
|
8720
10691
|
const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
|
|
@@ -8726,8 +10697,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
8726
10697
|
});
|
|
8727
10698
|
const files = [];
|
|
8728
10699
|
const write = async (name, text2) => {
|
|
8729
|
-
await
|
|
8730
|
-
files.push(
|
|
10700
|
+
await writeFile11(join11(out, name), text2);
|
|
10701
|
+
files.push(join11(out, name));
|
|
8731
10702
|
};
|
|
8732
10703
|
await write("index.html", deck.composition);
|
|
8733
10704
|
await write("hyperframes.json", HYPERFRAMES_JSON);
|
|
@@ -8756,8 +10727,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
8756
10727
|
}
|
|
8757
10728
|
if (deck.page) {
|
|
8758
10729
|
await write(DECK_PAGE, deck.page);
|
|
8759
|
-
await cp(playerBundle(),
|
|
8760
|
-
files.push(
|
|
10730
|
+
await cp(playerBundle(), join11(out, PLAYER_FILE));
|
|
10731
|
+
files.push(join11(out, PLAYER_FILE));
|
|
8761
10732
|
}
|
|
8762
10733
|
files.push(...await vendorKatex(out));
|
|
8763
10734
|
if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
|
|
@@ -8766,7 +10737,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
8766
10737
|
}
|
|
8767
10738
|
const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
|
|
8768
10739
|
step(
|
|
8769
|
-
`build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${
|
|
10740
|
+
`build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join11(out, "index.html")}`
|
|
8770
10741
|
);
|
|
8771
10742
|
for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
|
|
8772
10743
|
for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
|
|
@@ -8784,7 +10755,7 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
|
|
|
8784
10755
|
async function deckRuntime() {
|
|
8785
10756
|
const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
|
|
8786
10757
|
try {
|
|
8787
|
-
return await
|
|
10758
|
+
return await readFile11(path2, "utf8");
|
|
8788
10759
|
} catch {
|
|
8789
10760
|
throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
|
|
8790
10761
|
}
|
|
@@ -8792,57 +10763,57 @@ async function deckRuntime() {
|
|
|
8792
10763
|
function playerBundle() {
|
|
8793
10764
|
const require2 = createRequire2(import.meta.url);
|
|
8794
10765
|
try {
|
|
8795
|
-
return
|
|
10766
|
+
return join11(dirname3(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
|
|
8796
10767
|
} catch {
|
|
8797
10768
|
throw new Error('Cannot locate the hyperframes player. Run "npm install".');
|
|
8798
10769
|
}
|
|
8799
10770
|
}
|
|
8800
10771
|
async function vendorKatex(out) {
|
|
8801
10772
|
const require2 = createRequire2(import.meta.url);
|
|
8802
|
-
const dist =
|
|
8803
|
-
const css = await
|
|
10773
|
+
const dist = join11(dirname3(require2.resolve("katex/package.json")), "dist");
|
|
10774
|
+
const css = await readFile11(join11(dist, "katex.min.css"), "utf8");
|
|
8804
10775
|
const written = [];
|
|
8805
|
-
await
|
|
8806
|
-
for (const file of await
|
|
10776
|
+
await mkdir9(join11(out, "katex/fonts"), { recursive: true });
|
|
10777
|
+
for (const file of await readdir2(join11(dist, "fonts"))) {
|
|
8807
10778
|
if (!file.endsWith(".woff2")) continue;
|
|
8808
|
-
await cp(
|
|
8809
|
-
written.push(
|
|
10779
|
+
await cp(join11(dist, "fonts", file), join11(out, "katex/fonts", file));
|
|
10780
|
+
written.push(join11(out, "katex/fonts", file));
|
|
8810
10781
|
}
|
|
8811
10782
|
const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
|
|
8812
10783
|
const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
|
|
8813
10784
|
return kept ? `src:${kept}` : whole;
|
|
8814
10785
|
});
|
|
8815
|
-
await
|
|
8816
|
-
written.push(
|
|
10786
|
+
await writeFile11(join11(out, "katex/katex.min.css"), woff2Only);
|
|
10787
|
+
written.push(join11(out, "katex/katex.min.css"));
|
|
8817
10788
|
return written;
|
|
8818
10789
|
}
|
|
8819
10790
|
async function copyAssets(sourceDir, out) {
|
|
8820
|
-
const from =
|
|
8821
|
-
if (!await
|
|
8822
|
-
await cp(from,
|
|
8823
|
-
return [
|
|
10791
|
+
const from = join11(resolve4(sourceDir), "assets");
|
|
10792
|
+
if (!await stat2(from).catch(() => null)) return [];
|
|
10793
|
+
await cp(from, join11(out, "assets"), { recursive: true });
|
|
10794
|
+
return [join11(out, "assets")];
|
|
8824
10795
|
}
|
|
8825
10796
|
async function copyAudio(from, narration, out) {
|
|
8826
|
-
const dir =
|
|
8827
|
-
await
|
|
10797
|
+
const dir = join11(out, narration.dir);
|
|
10798
|
+
await mkdir9(dir, { recursive: true });
|
|
8828
10799
|
const names = [
|
|
8829
10800
|
...new Set(
|
|
8830
10801
|
Object.values(narration.beats).flat().map((s) => s.audio)
|
|
8831
10802
|
)
|
|
8832
10803
|
].sort();
|
|
8833
10804
|
for (const name of names) {
|
|
8834
|
-
await cp(
|
|
10805
|
+
await cp(join11(resolve4(from), name), join11(dir, name)).catch(() => {
|
|
8835
10806
|
throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
|
|
8836
10807
|
});
|
|
8837
10808
|
}
|
|
8838
|
-
return names.map((n3) =>
|
|
10809
|
+
return names.map((n3) => join11(dir, n3));
|
|
8839
10810
|
}
|
|
8840
10811
|
async function refreshFont(storyboard, source, out, step) {
|
|
8841
10812
|
try {
|
|
8842
10813
|
const bundle = await bundleFont(
|
|
8843
10814
|
storyboard.lang,
|
|
8844
10815
|
JSON.stringify(source) + JSON.stringify(storyboard),
|
|
8845
|
-
|
|
10816
|
+
join11(out, "assets", "fonts")
|
|
8846
10817
|
);
|
|
8847
10818
|
if (bundle) step(`build: font bundle covers ${bundle.family}`);
|
|
8848
10819
|
return bundle?.css;
|
|
@@ -8867,8 +10838,8 @@ var MARKDOWN_EXTS = [".md", ".markdown", ".txt"];
|
|
|
8867
10838
|
var UploadError = class extends Error {
|
|
8868
10839
|
hint;
|
|
8869
10840
|
status;
|
|
8870
|
-
constructor(
|
|
8871
|
-
super(
|
|
10841
|
+
constructor(message2, hint, status = 400) {
|
|
10842
|
+
super(message2);
|
|
8872
10843
|
this.name = "UploadError";
|
|
8873
10844
|
this.hint = hint;
|
|
8874
10845
|
this.status = status;
|
|
@@ -8879,10 +10850,10 @@ function looksLikeZip(bytes) {
|
|
|
8879
10850
|
}
|
|
8880
10851
|
function safeEntryPath(name) {
|
|
8881
10852
|
if (name.includes("\0")) return null;
|
|
8882
|
-
const
|
|
10853
|
+
const segments2 = name.replace(/\\/g, "/").split("/");
|
|
8883
10854
|
if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
|
|
8884
10855
|
const kept = [];
|
|
8885
|
-
for (const segment of
|
|
10856
|
+
for (const segment of segments2) {
|
|
8886
10857
|
if (segment === "" || segment === ".") continue;
|
|
8887
10858
|
if (segment === "..") return null;
|
|
8888
10859
|
kept.push(segment);
|
|
@@ -8925,14 +10896,14 @@ function readZip(bytes, limits = ZIP_LIMITS) {
|
|
|
8925
10896
|
}
|
|
8926
10897
|
if (entry.originalSize > limits.maxEntryBytes) {
|
|
8927
10898
|
throw new UploadError(
|
|
8928
|
-
`"${entry.name}" unpacks to ${
|
|
10899
|
+
`"${entry.name}" unpacks to ${mb2(entry.originalSize)}, over the ${mb2(limits.maxEntryBytes)} per-file limit.`,
|
|
8929
10900
|
"Leave the large file out; a deck reads the document and its figures, not the dataset."
|
|
8930
10901
|
);
|
|
8931
10902
|
}
|
|
8932
10903
|
total += entry.originalSize;
|
|
8933
10904
|
if (total > limits.maxTotalBytes) {
|
|
8934
10905
|
throw new UploadError(
|
|
8935
|
-
`The archive unpacks to more than ${
|
|
10906
|
+
`The archive unpacks to more than ${mb2(limits.maxTotalBytes)}.`,
|
|
8936
10907
|
"Send only the document and the figures it cites."
|
|
8937
10908
|
);
|
|
8938
10909
|
}
|
|
@@ -8959,7 +10930,7 @@ function readZip(bytes, limits = ZIP_LIMITS) {
|
|
|
8959
10930
|
actual += data.length;
|
|
8960
10931
|
if (actual > limits.maxTotalBytes) {
|
|
8961
10932
|
throw new UploadError(
|
|
8962
|
-
`The archive unpacks to more than ${
|
|
10933
|
+
`The archive unpacks to more than ${mb2(limits.maxTotalBytes)}.`,
|
|
8963
10934
|
"Send only the document and the figures it cites."
|
|
8964
10935
|
);
|
|
8965
10936
|
}
|
|
@@ -8992,7 +10963,7 @@ function pickMarkdown(files) {
|
|
|
8992
10963
|
function isNoise(name) {
|
|
8993
10964
|
return name.startsWith("__MACOSX/") || name.includes("/__MACOSX/") || posix.basename(name) === ".DS_Store" || posix.basename(name) === "Thumbs.db";
|
|
8994
10965
|
}
|
|
8995
|
-
function
|
|
10966
|
+
function mb2(bytes) {
|
|
8996
10967
|
return `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
8997
10968
|
}
|
|
8998
10969
|
|
|
@@ -9116,8 +11087,8 @@ function withCanvas(preset, fields, warnings) {
|
|
|
9116
11087
|
`Send both, or neither and let the "${preset.id}" preset decide.`
|
|
9117
11088
|
);
|
|
9118
11089
|
}
|
|
9119
|
-
const width =
|
|
9120
|
-
const height =
|
|
11090
|
+
const width = even2("width", fields.width, warnings);
|
|
11091
|
+
const height = even2("height", fields.height, warnings);
|
|
9121
11092
|
const problem = canvasProblem(width, height);
|
|
9122
11093
|
if (problem) {
|
|
9123
11094
|
throw new UploadError(
|
|
@@ -9134,7 +11105,7 @@ function withCanvas(preset, fields, warnings) {
|
|
|
9134
11105
|
warnings.push(...canvasWarnings(width, height));
|
|
9135
11106
|
return resizeFormat(preset, width, height);
|
|
9136
11107
|
}
|
|
9137
|
-
function
|
|
11108
|
+
function even2(name, raw2, warnings) {
|
|
9138
11109
|
const n3 = int(name, raw2);
|
|
9139
11110
|
if (n3 % 2 === 0 || !Number.isFinite(n3)) return n3;
|
|
9140
11111
|
warnings.push(`${name} ${n3} rounded down to ${n3 - 1}; h264 needs even dimensions`);
|
|
@@ -9197,11 +11168,18 @@ function int(name, raw2) {
|
|
|
9197
11168
|
}
|
|
9198
11169
|
|
|
9199
11170
|
// src/server/pipeline.ts
|
|
9200
|
-
import { mkdir as
|
|
9201
|
-
import { dirname as dirname4, join as
|
|
11171
|
+
import { mkdir as mkdir10, readFile as readFile12, stat as stat3, writeFile as writeFile12 } from "node:fs/promises";
|
|
11172
|
+
import { dirname as dirname4, isAbsolute, join as join12, relative, resolve as resolve5 } from "node:path";
|
|
9202
11173
|
var AUDIO_DIR = "audio";
|
|
9203
11174
|
var NARRATION_FILE = "narration.json";
|
|
9204
11175
|
var MAX_REMOTE_FIGURES = 40;
|
|
11176
|
+
var HARVEST_LIMITS = {
|
|
11177
|
+
maxBytes: 4 * 1024 * 1024,
|
|
11178
|
+
maxAssetBytes: 8 * 1024 * 1024,
|
|
11179
|
+
timeoutMs: 12e3,
|
|
11180
|
+
maxAssets: 24
|
|
11181
|
+
};
|
|
11182
|
+
var HARVEST_DEADLINE_MS = 9e4;
|
|
9205
11183
|
var FEED_FORMATS = /* @__PURE__ */ new Set(["short-9x16", "post-1x1"]);
|
|
9206
11184
|
function stagesFor(options) {
|
|
9207
11185
|
const stages = ["ingest", "plan"];
|
|
@@ -9214,10 +11192,10 @@ function stagesFor(options) {
|
|
|
9214
11192
|
async function runPipeline(job, input) {
|
|
9215
11193
|
const { options } = input;
|
|
9216
11194
|
const dirs = {
|
|
9217
|
-
upload:
|
|
9218
|
-
src:
|
|
9219
|
-
audio:
|
|
9220
|
-
deck:
|
|
11195
|
+
upload: join12(job.dir, "upload"),
|
|
11196
|
+
src: join12(job.dir, "src"),
|
|
11197
|
+
audio: join12(job.dir, AUDIO_DIR),
|
|
11198
|
+
deck: join12(job.dir, "deck")
|
|
9221
11199
|
};
|
|
9222
11200
|
const warnings = [...options.warnings];
|
|
9223
11201
|
const url = (rel) => `/d/${job.id}/${rel}`;
|
|
@@ -9241,21 +11219,21 @@ async function runPipeline(job, input) {
|
|
|
9241
11219
|
theme: options.stated.theme ? prefs.theme : planned.theme
|
|
9242
11220
|
};
|
|
9243
11221
|
assertRefsResolve(storyboard, source, { pending: options.images ? "allow" : "refuse" });
|
|
9244
|
-
await writeJson(
|
|
11222
|
+
await writeJson(join12(job.dir, "storyboard.json"), storyboard);
|
|
9245
11223
|
job.done("plan", `${storyboard.beats.length} beats`);
|
|
9246
11224
|
for (const f of scanBeatCount(storyboard, prefs)) warnings.push(f.message);
|
|
9247
11225
|
if (options.images) {
|
|
9248
11226
|
job.begin("illustrate");
|
|
9249
11227
|
const drawn = await illustrate(storyboard, source, {
|
|
9250
11228
|
prefs,
|
|
9251
|
-
assetsDir:
|
|
11229
|
+
assetsDir: join12(dirs.src, "assets"),
|
|
9252
11230
|
...input.imageChain ? { chain: input.imageChain } : {},
|
|
9253
11231
|
onStep: (line2) => job.log(line2)
|
|
9254
11232
|
});
|
|
9255
11233
|
storyboard = drawn.storyboard;
|
|
9256
11234
|
source = drawn.source;
|
|
9257
|
-
await writeJson(
|
|
9258
|
-
await writeJson(
|
|
11235
|
+
await writeJson(join12(dirs.src, "source.json"), source);
|
|
11236
|
+
await writeJson(join12(job.dir, "storyboard.json"), storyboard);
|
|
9259
11237
|
for (const p of drawn.illustrated) {
|
|
9260
11238
|
job.log(
|
|
9261
11239
|
`illustrate: ${p.beatId} \u2192 assets/${p.src} via ${p.provider}${p.cached ? " (cached)" : ""}`
|
|
@@ -9277,16 +11255,16 @@ async function runPipeline(job, input) {
|
|
|
9277
11255
|
warnings.push("the plan had nothing to say aloud, so the deck is silent");
|
|
9278
11256
|
} else {
|
|
9279
11257
|
job.log(`narrate: ${speaking} of ${storyboard.beats.length} beats speak`);
|
|
9280
|
-
await
|
|
11258
|
+
await mkdir10(dirs.audio, { recursive: true });
|
|
9281
11259
|
const spoken = await narrate(storyboard, source, prefs, {
|
|
9282
11260
|
dir: dirs.audio,
|
|
9283
11261
|
format: options.format
|
|
9284
11262
|
});
|
|
9285
|
-
await writeJson(
|
|
11263
|
+
await writeJson(join12(dirs.audio, NARRATION_FILE), spoken);
|
|
9286
11264
|
narration = { voice: spoken.voice, dir: AUDIO_DIR, beats: spoken.beats };
|
|
9287
|
-
const
|
|
9288
|
-
const seconds =
|
|
9289
|
-
job.done("narrate", `${
|
|
11265
|
+
const segments2 = Object.values(spoken.beats).flat();
|
|
11266
|
+
const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
|
|
11267
|
+
job.done("narrate", `${segments2.length} segments, ${seconds.toFixed(1)}s in ${spoken.voice}`);
|
|
9290
11268
|
}
|
|
9291
11269
|
}
|
|
9292
11270
|
job.begin("build");
|
|
@@ -9329,7 +11307,7 @@ async function runPipeline(job, input) {
|
|
|
9329
11307
|
job.log("render: capturing frames \u2014 two minutes for a four-minute video");
|
|
9330
11308
|
const out = await render({
|
|
9331
11309
|
deck: dirs.deck,
|
|
9332
|
-
out:
|
|
11310
|
+
out: join12(dirs.deck, "video.mp4"),
|
|
9333
11311
|
subtitles: "sidecar",
|
|
9334
11312
|
log: (line2) => job.log(line2),
|
|
9335
11313
|
// The last of the three length levers, and the only one that acts on a
|
|
@@ -9378,35 +11356,61 @@ async function runPipeline(job, input) {
|
|
|
9378
11356
|
};
|
|
9379
11357
|
}
|
|
9380
11358
|
async function ingest(job, input, dirs, warnings) {
|
|
9381
|
-
await
|
|
9382
|
-
const root2 =
|
|
11359
|
+
await mkdir10(dirs.upload, { recursive: true });
|
|
11360
|
+
const root2 = resolve5(dirs.upload);
|
|
9383
11361
|
let docPath;
|
|
9384
|
-
|
|
9385
|
-
|
|
11362
|
+
const upload = input.upload;
|
|
11363
|
+
const bothOrNeither = new UploadError(
|
|
11364
|
+
"A job needs exactly one of an upload and a url.",
|
|
11365
|
+
"PipelineInput carries `upload` or `url`; it was handed both, or neither.",
|
|
11366
|
+
500
|
|
11367
|
+
);
|
|
11368
|
+
if (input.url !== void 0) {
|
|
11369
|
+
if (upload !== void 0) throw bothOrNeither;
|
|
11370
|
+
const harvested = await harvestBounded(input.url, join12(root2, "assets"), {
|
|
11371
|
+
...HARVEST_LIMITS,
|
|
11372
|
+
...input.harvest
|
|
11373
|
+
});
|
|
11374
|
+
warnings.push(...harvested.warnings);
|
|
11375
|
+
docPath = join12(root2, "document.md");
|
|
11376
|
+
await writeFile12(docPath, harvested.markdown);
|
|
11377
|
+
job.log(
|
|
11378
|
+
`ingest: harvested ${input.url} \u2014 "${harvested.title}", ${harvested.assets.length} asset(s)`
|
|
11379
|
+
);
|
|
11380
|
+
if (harvested.markdown.trim() === "") {
|
|
11381
|
+
throw new UploadError(
|
|
11382
|
+
`Nothing readable came back from ${input.url}.`,
|
|
11383
|
+
"A page that builds its body from a third-party script harvests to nothing, because the harvester runs none of them \u2014 that trade is written up at the top of src/source/harvest.ts. Save the page as markdown and upload the file instead."
|
|
11384
|
+
);
|
|
11385
|
+
}
|
|
11386
|
+
} else if (upload === void 0) {
|
|
11387
|
+
throw bothOrNeither;
|
|
11388
|
+
} else if (looksLikeZip(upload.bytes)) {
|
|
11389
|
+
const { files, warnings: zipWarnings } = readZip(upload.bytes);
|
|
9386
11390
|
warnings.push(...zipWarnings);
|
|
9387
11391
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
9388
|
-
const to =
|
|
11392
|
+
const to = resolve5(join12(root2, rel));
|
|
9389
11393
|
if (!insideRoot(root2, to))
|
|
9390
11394
|
throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
|
|
9391
|
-
await
|
|
9392
|
-
await
|
|
11395
|
+
await mkdir10(dirname4(to), { recursive: true });
|
|
11396
|
+
await writeFile12(to, bytes);
|
|
9393
11397
|
}
|
|
9394
|
-
docPath =
|
|
11398
|
+
docPath = join12(root2, pickMarkdown(files));
|
|
9395
11399
|
job.log(
|
|
9396
11400
|
`ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
|
|
9397
11401
|
);
|
|
9398
11402
|
} else {
|
|
9399
|
-
const ext = (
|
|
11403
|
+
const ext = (upload.filename.match(/\.[^.\\/]+$/)?.[0] ?? "").toLowerCase();
|
|
9400
11404
|
if (ext && !MARKDOWN_EXTS.includes(ext)) {
|
|
9401
11405
|
throw new UploadError(
|
|
9402
|
-
`"${
|
|
11406
|
+
`"${upload.filename}" is a ${ext} file.`,
|
|
9403
11407
|
`DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
|
|
9404
11408
|
);
|
|
9405
11409
|
}
|
|
9406
|
-
docPath =
|
|
9407
|
-
await
|
|
11410
|
+
docPath = join12(root2, "document.md");
|
|
11411
|
+
await writeFile12(docPath, upload.bytes);
|
|
9408
11412
|
}
|
|
9409
|
-
const text2 = await
|
|
11413
|
+
const text2 = await readFile12(docPath, "utf8");
|
|
9410
11414
|
const parsed = parseMarkdown(text2, {
|
|
9411
11415
|
lang: input.options.stated.lang ? input.options.prefs.lang : void 0
|
|
9412
11416
|
});
|
|
@@ -9426,18 +11430,30 @@ async function ingest(job, input, dirs, warnings) {
|
|
|
9426
11430
|
`ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
|
|
9427
11431
|
);
|
|
9428
11432
|
const guarded = await guardFigures(parsed, root2, input.fetchRemoteFigures, warnings);
|
|
9429
|
-
const source = await fetchFigures(guarded,
|
|
9430
|
-
await writeJson(
|
|
11433
|
+
const source = await fetchFigures(guarded, join12(resolve5(dirs.src), "assets"), warnings);
|
|
11434
|
+
await writeJson(join12(dirs.src, "source.json"), source);
|
|
9431
11435
|
return source;
|
|
9432
11436
|
}
|
|
9433
|
-
|
|
9434
|
-
|
|
9435
|
-
|
|
9436
|
-
|
|
9437
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
9440
|
-
|
|
11437
|
+
async function harvestBounded(url, dir, opts) {
|
|
11438
|
+
let timer;
|
|
11439
|
+
const deadline = new Promise((_, reject) => {
|
|
11440
|
+
timer = setTimeout(
|
|
11441
|
+
() => reject(
|
|
11442
|
+
new UploadError(
|
|
11443
|
+
`${url} took longer than ${HARVEST_DEADLINE_MS / 1e3}s to read.`,
|
|
11444
|
+
"The page or its images are answering too slowly to build a deck from. Save the page as markdown and upload the file instead.",
|
|
11445
|
+
504
|
|
11446
|
+
)
|
|
11447
|
+
),
|
|
11448
|
+
HARVEST_DEADLINE_MS
|
|
11449
|
+
);
|
|
11450
|
+
});
|
|
11451
|
+
try {
|
|
11452
|
+
return await Promise.race([harvest(url, dir, opts), deadline]);
|
|
11453
|
+
} finally {
|
|
11454
|
+
clearTimeout(timer);
|
|
11455
|
+
}
|
|
11456
|
+
}
|
|
9441
11457
|
async function reachable(url) {
|
|
9442
11458
|
let host2;
|
|
9443
11459
|
try {
|
|
@@ -9454,11 +11470,8 @@ async function reachable(url) {
|
|
|
9454
11470
|
return "does not resolve";
|
|
9455
11471
|
}
|
|
9456
11472
|
for (const addr of addrs) {
|
|
9457
|
-
|
|
9458
|
-
|
|
9459
|
-
if (v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd")) {
|
|
9460
|
-
return `resolves to a private address (${addr})`;
|
|
9461
|
-
}
|
|
11473
|
+
const why2 = isBlockedAddress(addr);
|
|
11474
|
+
if (why2) return `resolves to a private address (${addr})`;
|
|
9462
11475
|
}
|
|
9463
11476
|
return addrs.length === 0 ? "does not resolve" : null;
|
|
9464
11477
|
}
|
|
@@ -9475,17 +11488,18 @@ async function guardFigures(source, root2, allowRemote, warnings) {
|
|
|
9475
11488
|
);
|
|
9476
11489
|
continue;
|
|
9477
11490
|
}
|
|
9478
|
-
if (
|
|
11491
|
+
if (remote >= MAX_REMOTE_FIGURES) {
|
|
9479
11492
|
warnings.push(
|
|
9480
11493
|
`figure ${figure.id} was left out: more than ${MAX_REMOTE_FIGURES} remote figures`
|
|
9481
11494
|
);
|
|
9482
11495
|
continue;
|
|
9483
11496
|
}
|
|
9484
|
-
const
|
|
9485
|
-
if (
|
|
9486
|
-
warnings.push(`figure ${figure.id} was left out: ${host(src)} ${
|
|
11497
|
+
const why2 = await reachable(src);
|
|
11498
|
+
if (why2) {
|
|
11499
|
+
warnings.push(`figure ${figure.id} was left out: ${host(src)} ${why2}`);
|
|
9487
11500
|
continue;
|
|
9488
11501
|
}
|
|
11502
|
+
remote++;
|
|
9489
11503
|
figures.push(figure);
|
|
9490
11504
|
continue;
|
|
9491
11505
|
}
|
|
@@ -9493,12 +11507,12 @@ async function guardFigures(source, root2, allowRemote, warnings) {
|
|
|
9493
11507
|
warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
|
|
9494
11508
|
continue;
|
|
9495
11509
|
}
|
|
9496
|
-
const abs =
|
|
11510
|
+
const abs = isAbsolute(src) ? resolve5(src) : resolve5(join12(root2, src.replace(/^\.?\//, "")));
|
|
9497
11511
|
if (!insideRoot(root2, abs)) {
|
|
9498
11512
|
warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
|
|
9499
11513
|
continue;
|
|
9500
11514
|
}
|
|
9501
|
-
if (!(await
|
|
11515
|
+
if (!(await stat3(abs).catch(() => null))?.isFile()) {
|
|
9502
11516
|
warnings.push(`figure ${figure.id} was left out: "${src}" is not in the upload`);
|
|
9503
11517
|
continue;
|
|
9504
11518
|
}
|
|
@@ -9508,19 +11522,19 @@ async function guardFigures(source, root2, allowRemote, warnings) {
|
|
|
9508
11522
|
}
|
|
9509
11523
|
async function pack2(job, ctx) {
|
|
9510
11524
|
try {
|
|
9511
|
-
const assets =
|
|
11525
|
+
const assets = join12(resolve5(ctx.dirs.src), "assets");
|
|
9512
11526
|
const requests = ctx.source.figures.map((f) => ({
|
|
9513
11527
|
id: f.id,
|
|
9514
|
-
url:
|
|
11528
|
+
url: join12(assets, f.src),
|
|
9515
11529
|
prefer: "bake"
|
|
9516
11530
|
}));
|
|
9517
11531
|
const plan = await planMedia(requests, async (url) => ({
|
|
9518
|
-
bytes: new Uint8Array(await
|
|
11532
|
+
bytes: new Uint8Array(await readFile12(url))
|
|
9519
11533
|
}));
|
|
9520
11534
|
const files = { ...plan.files };
|
|
9521
11535
|
if (ctx.narration) {
|
|
9522
11536
|
for (const name of audioNames(ctx.narration)) {
|
|
9523
|
-
files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await
|
|
11537
|
+
files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile12(join12(ctx.dirs.audio, name)));
|
|
9524
11538
|
}
|
|
9525
11539
|
}
|
|
9526
11540
|
const container = {
|
|
@@ -9537,10 +11551,10 @@ async function pack2(job, ctx) {
|
|
|
9537
11551
|
},
|
|
9538
11552
|
source: ctx.source,
|
|
9539
11553
|
storyboard: ctx.storyboard,
|
|
9540
|
-
...ctx.narration ? { narration: await readJson(
|
|
11554
|
+
...ctx.narration ? { narration: await readJson(join12(ctx.dirs.audio, NARRATION_FILE)) } : {},
|
|
9541
11555
|
media: plan.media
|
|
9542
11556
|
};
|
|
9543
|
-
const bytes = await writePack(container, files,
|
|
11557
|
+
const bytes = await writePack(container, files, join12(ctx.dirs.deck, "deck.deck"));
|
|
9544
11558
|
job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
|
|
9545
11559
|
return ctx.url("deck.deck");
|
|
9546
11560
|
} catch (err) {
|
|
@@ -9563,12 +11577,12 @@ function host(src) {
|
|
|
9563
11577
|
}
|
|
9564
11578
|
}
|
|
9565
11579
|
async function writeJson(path2, value) {
|
|
9566
|
-
await
|
|
9567
|
-
await
|
|
11580
|
+
await mkdir10(dirname4(path2), { recursive: true });
|
|
11581
|
+
await writeFile12(path2, `${JSON.stringify(value, null, 2)}
|
|
9568
11582
|
`);
|
|
9569
11583
|
}
|
|
9570
11584
|
async function readJson(path2) {
|
|
9571
|
-
return JSON.parse(await
|
|
11585
|
+
return JSON.parse(await readFile12(path2, "utf8"));
|
|
9572
11586
|
}
|
|
9573
11587
|
|
|
9574
11588
|
// src/server/errors.ts
|
|
@@ -9664,9 +11678,9 @@ var HINTS = [
|
|
|
9664
11678
|
var FALLBACK = "This one is not a document problem we recognise. The stage list above shows how far it got; re-running an identical upload is worth one try.";
|
|
9665
11679
|
function explain(err) {
|
|
9666
11680
|
if (err instanceof UploadError) return { message: err.message, hint: err.hint };
|
|
9667
|
-
const
|
|
9668
|
-
const clean2 =
|
|
9669
|
-
return { message: clean2, hint: HINTS.find((h) => h.when.test(
|
|
11681
|
+
const message2 = err instanceof Error ? err.message : String(err);
|
|
11682
|
+
const clean2 = message2.split("\n at ")[0]?.trim() || "The job failed.";
|
|
11683
|
+
return { message: clean2, hint: HINTS.find((h) => h.when.test(message2))?.hint ?? FALLBACK };
|
|
9670
11684
|
}
|
|
9671
11685
|
|
|
9672
11686
|
// src/server/queue.ts
|
|
@@ -9827,10 +11841,10 @@ var Queue = class {
|
|
|
9827
11841
|
if (detail) step.detail = detail;
|
|
9828
11842
|
this.#emit(job.id);
|
|
9829
11843
|
},
|
|
9830
|
-
skip: (stage,
|
|
11844
|
+
skip: (stage, why2) => {
|
|
9831
11845
|
const step = this.#step(job, stage);
|
|
9832
11846
|
step.state = "skipped";
|
|
9833
|
-
step.detail =
|
|
11847
|
+
step.detail = why2;
|
|
9834
11848
|
this.#emit(job.id);
|
|
9835
11849
|
},
|
|
9836
11850
|
log: (line2) => this.#note(job, line2)
|
|
@@ -9909,37 +11923,37 @@ function missingFor(found, opts) {
|
|
|
9909
11923
|
// src/mcp/tools.ts
|
|
9910
11924
|
var formatIds = Object.keys(FORMATS);
|
|
9911
11925
|
var themeIds = [...THEME_NAMES];
|
|
9912
|
-
var settingsSchema =
|
|
9913
|
-
format:
|
|
11926
|
+
var settingsSchema = z5.object({
|
|
11927
|
+
format: z5.enum(formatIds).optional().describe(
|
|
9914
11928
|
"deck-16x9 is a navigable slide deck. video-16x9, short-9x16 and post-1x1 are linear and budgeted \u2014 they drop beats that do not fit their length."
|
|
9915
11929
|
),
|
|
9916
|
-
theme:
|
|
9917
|
-
lang:
|
|
11930
|
+
theme: z5.enum(themeIds).optional().describe("Omit and the storyboard's own theme wins."),
|
|
11931
|
+
lang: z5.string().regex(/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/).optional().describe(
|
|
9918
11932
|
"BCP-47. OMIT THIS unless you want a translation \u2014 absent means the document's own language."
|
|
9919
11933
|
),
|
|
9920
|
-
tone:
|
|
9921
|
-
density:
|
|
9922
|
-
genre:
|
|
11934
|
+
tone: z5.enum(["plain", "academic", "conversational", "punchy"]).optional(),
|
|
11935
|
+
density: z5.enum(["sparse", "normal", "dense"]).optional().describe("How much text a SLIDE carries. A different axis from narration_density."),
|
|
11936
|
+
genre: z5.enum(["general", "paper"]).optional().describe(
|
|
9923
11937
|
"DECLARED, never detected. `paper` asks for a research-talk shape \u2014 an introduction and background at the front, a limitations slide and then a conclusion at the end \u2014 and reports where the deck missed it. There is no detector: the documents this tool ingests are analyses OF papers, rewritten in a way that drops the headings a detector would need, so nothing infers this and absent means `general`."
|
|
9924
11938
|
),
|
|
9925
|
-
duration:
|
|
11939
|
+
duration: z5.number().min(10).max(1800).optional().describe(
|
|
9926
11940
|
"Target seconds. Sets the pace, derives the slide count when you give none, and overrides animation_speed. Call decksmith_estimate_length first \u2014 a short target buys its words by saying less, not by playing faster."
|
|
9927
11941
|
),
|
|
9928
|
-
slides:
|
|
11942
|
+
slides: z5.int().min(3).max(40).optional().describe(
|
|
9929
11943
|
"Target beat count. Derived from duration when omitted. Stating it is obeyed even when it cannot flow, and you are told what it cost."
|
|
9930
11944
|
),
|
|
9931
|
-
animation_speed:
|
|
9932
|
-
narrate:
|
|
9933
|
-
voice:
|
|
9934
|
-
rate:
|
|
9935
|
-
pitch:
|
|
9936
|
-
narration_density:
|
|
11945
|
+
animation_speed: z5.number().min(0.25).max(3).optional().describe("Multiplies every tween. IGNORED when duration is set."),
|
|
11946
|
+
narrate: z5.boolean().optional().describe("Speak the deck with edge-tts. Required for video."),
|
|
11947
|
+
voice: z5.string().optional().describe('e.g. "en-US-AndrewMultilingualNeural". Omit to have one chosen for lang and tone.'),
|
|
11948
|
+
rate: z5.string().regex(/^[+-]\d{1,3}%$/).optional().describe("IGNORED when duration is set."),
|
|
11949
|
+
pitch: z5.string().regex(/^[+-]\d{1,3}Hz$/).optional(),
|
|
11950
|
+
narration_density: z5.enum(["high", "medium", "low"]).optional().describe(
|
|
9937
11951
|
"How many of a beat's stops SPEAK. `low` is one sentence a beat and is what makes a short target reachable \u2014 the demo has 12 beats but 37 stops, so a 60s target spread over stops is four words each."
|
|
9938
11952
|
),
|
|
9939
|
-
video:
|
|
11953
|
+
video: z5.boolean().optional().describe(
|
|
9940
11954
|
"Also render an mp4. Adds 1-2 minutes, needs ffmpeg and Chrome, and turns narrate on."
|
|
9941
11955
|
),
|
|
9942
|
-
images:
|
|
11956
|
+
images: z5.boolean().optional().describe(
|
|
9943
11957
|
"Let the plan ask for a picture where the document has no figure to show. Each brief is drawn through the configured image backend, else the Codex account's own image tool, else an SVG DeckSmith draws itself \u2014 so it never fails for lack of one. Adds about 30 seconds a picture; decksmith_capabilities says which backend is configured."
|
|
9944
11958
|
)
|
|
9945
11959
|
});
|
|
@@ -9966,34 +11980,64 @@ function fieldsFor(s) {
|
|
|
9966
11980
|
put("images", s.images);
|
|
9967
11981
|
return f;
|
|
9968
11982
|
}
|
|
9969
|
-
var
|
|
9970
|
-
|
|
11983
|
+
var PAGE_BUDGET = {
|
|
11984
|
+
maxAssetBytes: 8 * 1024 * 1024,
|
|
11985
|
+
maxTotalBytes: 16 * 1024 * 1024,
|
|
11986
|
+
maxWallMs: 6e4,
|
|
11987
|
+
maxClips: 0,
|
|
11988
|
+
refs: "relative"
|
|
11989
|
+
};
|
|
11990
|
+
async function readPage(url, work2) {
|
|
11991
|
+
await mkdir11(work2, { recursive: true });
|
|
11992
|
+
const dir = await mkdtemp3(join13(work2, "harvest-"));
|
|
11993
|
+
try {
|
|
11994
|
+
const page = await harvest(url, dir, PAGE_BUDGET);
|
|
11995
|
+
const files = {
|
|
11996
|
+
"document.md": new TextEncoder().encode(page.markdown)
|
|
11997
|
+
};
|
|
11998
|
+
for (const asset of page.assets) files[basename4(asset)] = await readFile13(asset);
|
|
11999
|
+
const warnings = [...page.warnings];
|
|
12000
|
+
if (page.clips.length > 0) {
|
|
12001
|
+
warnings.push(
|
|
12002
|
+
`${page.clips.length} video(s) travel as their still image and a link only: this tool ships the page as a markdown document, which has no way to say kind: "clip". \`decksmith ingest <url>\` keeps them as clips.`
|
|
12003
|
+
);
|
|
12004
|
+
}
|
|
12005
|
+
return { zip: zipSync2(files), warnings };
|
|
12006
|
+
} finally {
|
|
12007
|
+
await rm8(dir, { recursive: true, force: true });
|
|
12008
|
+
}
|
|
12009
|
+
}
|
|
12010
|
+
var capabilitiesSchema = z5.object({});
|
|
12011
|
+
var estimateSchema = z5.object({
|
|
9971
12012
|
settings: settingsSchema.describe("The same settings you would pass to decksmith_create_deck.")
|
|
9972
12013
|
});
|
|
9973
|
-
var createSchema =
|
|
9974
|
-
document_path:
|
|
9975
|
-
document_text:
|
|
12014
|
+
var createSchema = z5.object({
|
|
12015
|
+
document_path: z5.string().optional().describe("Absolute path to a markdown file. Must sit under the server's root."),
|
|
12016
|
+
document_text: z5.string().optional().describe("The markdown itself, if you have no file."),
|
|
12017
|
+
document_url: z5.string().optional().describe(
|
|
12018
|
+
"An http(s) page to read the document out of. The page is fetched through the same guard as every other URL, opened in a browser that is allowed to request nothing, and reduced to its prose, tables, code and figures; the figures are downloaded and measured here. Videos come back as their still image and a link \u2014 the deck cannot play a video it reached this way. Anything the harvest left out is listed in harvest_warnings on the answer."
|
|
12019
|
+
),
|
|
9976
12020
|
settings: settingsSchema.optional(),
|
|
9977
|
-
wait_seconds:
|
|
12021
|
+
wait_seconds: z5.number().min(0).max(300).optional().describe(
|
|
9978
12022
|
"How long to block before answering. Default 45. The job keeps running when this expires \u2014 poll decksmith_job_status with the id."
|
|
9979
12023
|
)
|
|
9980
12024
|
});
|
|
9981
|
-
var statusSchema =
|
|
9982
|
-
job_id:
|
|
9983
|
-
wait_seconds:
|
|
12025
|
+
var statusSchema = z5.object({
|
|
12026
|
+
job_id: z5.string(),
|
|
12027
|
+
wait_seconds: z5.number().min(0).max(300).optional().describe("Block up to this long for the job to finish or change stage. Default 45.")
|
|
9984
12028
|
});
|
|
9985
12029
|
var DEFAULT_WAIT = 45;
|
|
9986
12030
|
function deckTools(opts) {
|
|
9987
12031
|
const queue = new Queue({ maxQueued: 8 });
|
|
9988
|
-
const root2 =
|
|
12032
|
+
const root2 = resolve6(opts.root);
|
|
9989
12033
|
let cached;
|
|
9990
12034
|
const found = async () => {
|
|
9991
12035
|
cached ??= await (opts.probe ?? prereqs)();
|
|
9992
12036
|
return cached;
|
|
9993
12037
|
};
|
|
9994
12038
|
const insideRoot2 = (p) => {
|
|
9995
|
-
if (!
|
|
9996
|
-
const full =
|
|
12039
|
+
if (!isAbsolute2(p)) throw new Error(`document_path must be absolute; got "${p}".`);
|
|
12040
|
+
const full = resolve6(p);
|
|
9997
12041
|
if (full !== root2 && !full.startsWith(root2 + sep2)) {
|
|
9998
12042
|
throw new Error(
|
|
9999
12043
|
`document_path is outside the server's root. It may only read files under ${root2}.`
|
|
@@ -10049,8 +12093,9 @@ function deckTools(opts) {
|
|
|
10049
12093
|
},
|
|
10050
12094
|
/** Submit a document, wait a bounded slice, report. */
|
|
10051
12095
|
async create(input) {
|
|
10052
|
-
|
|
10053
|
-
|
|
12096
|
+
const given = [input.document_path, input.document_text, input.document_url].filter(Boolean);
|
|
12097
|
+
if (given.length !== 1) {
|
|
12098
|
+
throw new Error("Pass exactly one of document_path, document_text or document_url.");
|
|
10054
12099
|
}
|
|
10055
12100
|
const settings = input.settings ?? {};
|
|
10056
12101
|
const options = parseOptions(fieldsFor(settings));
|
|
@@ -10069,16 +12114,17 @@ function deckTools(opts) {
|
|
|
10069
12114
|
);
|
|
10070
12115
|
}
|
|
10071
12116
|
}
|
|
10072
|
-
const
|
|
10073
|
-
const
|
|
12117
|
+
const page = input.document_url === void 0 ? void 0 : await readPage(input.document_url, opts.work);
|
|
12118
|
+
const filename = page ? "page.zip" : input.document_path ? input.document_path.split(sep2).pop() : "document.md";
|
|
12119
|
+
const bytes = page ? page.zip : file ? await readFile13(file) : new TextEncoder().encode(input.document_text);
|
|
10074
12120
|
if (bytes.byteLength > MAX_UPLOAD_BYTES) {
|
|
10075
12121
|
throw new Error(
|
|
10076
|
-
`Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.`
|
|
12122
|
+
`Document is ${(bytes.byteLength / 1e6).toFixed(1)} MB, over the ${(MAX_UPLOAD_BYTES / 1e6).toFixed(0)} MB cap.` + (page ? " Harvest fewer figures with a smaller maxAssets, or save the page and edit it." : "")
|
|
10077
12123
|
);
|
|
10078
12124
|
}
|
|
10079
12125
|
const id2 = randomBytes(16).toString("base64url");
|
|
10080
|
-
const dir =
|
|
10081
|
-
await
|
|
12126
|
+
const dir = join13(opts.work, id2);
|
|
12127
|
+
await mkdir11(dir, { recursive: true });
|
|
10082
12128
|
queue.submit({
|
|
10083
12129
|
id: id2,
|
|
10084
12130
|
dir,
|
|
@@ -10092,7 +12138,8 @@ function deckTools(opts) {
|
|
|
10092
12138
|
fetchRemoteFigures: true
|
|
10093
12139
|
})
|
|
10094
12140
|
});
|
|
10095
|
-
|
|
12141
|
+
const view = await this.status({ job_id: id2, wait_seconds: input.wait_seconds });
|
|
12142
|
+
return page === void 0 ? view : { ...view, harvest_warnings: page.warnings };
|
|
10096
12143
|
},
|
|
10097
12144
|
/** Poll, blocking up to `wait_seconds` for the job to move on. */
|
|
10098
12145
|
async status(input) {
|
|
@@ -10104,7 +12151,7 @@ function deckTools(opts) {
|
|
|
10104
12151
|
};
|
|
10105
12152
|
}
|
|
10106
12153
|
function dirOf(work2, id2) {
|
|
10107
|
-
return
|
|
12154
|
+
return join13(work2, id2);
|
|
10108
12155
|
}
|
|
10109
12156
|
function waitFor(queue, id2, seconds) {
|
|
10110
12157
|
const now = queue.view(id2);
|
|
@@ -10136,9 +12183,9 @@ function report(view, dir) {
|
|
|
10136
12183
|
// (src/server/pipeline.ts). This said `src/storyboard.json` for one run, which
|
|
10137
12184
|
// is the shape of the CLI's output directory and not the server's, and an
|
|
10138
12185
|
// agent told to read it would have found nothing there.
|
|
10139
|
-
storyboard_path:
|
|
12186
|
+
storyboard_path: join13(dir, "storyboard.json"),
|
|
10140
12187
|
...done && view.result ? {
|
|
10141
|
-
deck_path:
|
|
12188
|
+
deck_path: join13(dir, "deck"),
|
|
10142
12189
|
slides: view.result.slides,
|
|
10143
12190
|
duration_seconds: view.result.duration,
|
|
10144
12191
|
warnings: view.result.warnings
|
|
@@ -10147,7 +12194,7 @@ function report(view, dir) {
|
|
|
10147
12194
|
};
|
|
10148
12195
|
}
|
|
10149
12196
|
function defaultWork() {
|
|
10150
|
-
return
|
|
12197
|
+
return join13(tmpdir3(), "decksmith-mcp");
|
|
10151
12198
|
}
|
|
10152
12199
|
|
|
10153
12200
|
// src/mcp/main.ts
|
|
@@ -10161,7 +12208,7 @@ var env = process.env;
|
|
|
10161
12208
|
var root = env.DECKSMITH_MCP_ROOT ?? homedir3();
|
|
10162
12209
|
var work = env.DECKSMITH_MCP_WORK ?? defaultWork();
|
|
10163
12210
|
var tools = deckTools({ root, work });
|
|
10164
|
-
var schema = (s) =>
|
|
12211
|
+
var schema = (s) => z6.toJSONSchema(s, { io: "input", unrepresentable: "any", target: "draft-2020-12" });
|
|
10165
12212
|
var TOOLS = [
|
|
10166
12213
|
{
|
|
10167
12214
|
name: "decksmith_capabilities",
|