@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/index.js
CHANGED
|
@@ -1,9 +1,243 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { cp, mkdir as
|
|
3
|
-
import { createRequire as
|
|
4
|
-
import { dirname as dirname4, join as
|
|
2
|
+
import { cp, mkdir as mkdir10, readdir as readdir4, readFile as readFile17, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
|
|
3
|
+
import { createRequire as createRequire3 } from "node:module";
|
|
4
|
+
import { dirname as dirname4, join as join16, resolve as resolve5 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
+
// src/pack/media.ts
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { extname } from "node:path";
|
|
11
|
+
var PLAYERS = [
|
|
12
|
+
"youtube.com",
|
|
13
|
+
"youtube-nocookie.com",
|
|
14
|
+
"youtu.be",
|
|
15
|
+
"vimeo.com",
|
|
16
|
+
"dailymotion.com",
|
|
17
|
+
"dai.ly",
|
|
18
|
+
"twitch.tv",
|
|
19
|
+
"loom.com",
|
|
20
|
+
"wistia.com",
|
|
21
|
+
"wistia.net",
|
|
22
|
+
"streamable.com",
|
|
23
|
+
"bilibili.com",
|
|
24
|
+
"soundcloud.com",
|
|
25
|
+
"tiktok.com"
|
|
26
|
+
];
|
|
27
|
+
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
28
|
+
".png",
|
|
29
|
+
".jpg",
|
|
30
|
+
".jpeg",
|
|
31
|
+
".gif",
|
|
32
|
+
".webp",
|
|
33
|
+
".avif",
|
|
34
|
+
".svg",
|
|
35
|
+
".mp4",
|
|
36
|
+
".webm",
|
|
37
|
+
".mov",
|
|
38
|
+
".m4v",
|
|
39
|
+
".mp3",
|
|
40
|
+
".m4a",
|
|
41
|
+
".wav",
|
|
42
|
+
".ogg",
|
|
43
|
+
".opus",
|
|
44
|
+
".pdf"
|
|
45
|
+
]);
|
|
46
|
+
function isEmbed(url) {
|
|
47
|
+
const host = hostOf(url);
|
|
48
|
+
return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
|
|
49
|
+
}
|
|
50
|
+
function segments(u) {
|
|
51
|
+
return u.pathname.split("/").filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
function token(raw2) {
|
|
54
|
+
return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
|
|
55
|
+
}
|
|
56
|
+
var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
|
|
57
|
+
var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
|
|
58
|
+
var YOUTUBE = {
|
|
59
|
+
origin: YOUTUBE_EMBED,
|
|
60
|
+
embed: (u) => {
|
|
61
|
+
const seg = segments(u);
|
|
62
|
+
const path2 = seg[0];
|
|
63
|
+
return youtube(
|
|
64
|
+
token(
|
|
65
|
+
path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var YOUTU_BE = {
|
|
71
|
+
origin: YOUTUBE_EMBED,
|
|
72
|
+
embed: (u) => youtube(token(segments(u)[0]))
|
|
73
|
+
};
|
|
74
|
+
var VIMEO_EMBED = "https://player.vimeo.com";
|
|
75
|
+
var VIMEO = {
|
|
76
|
+
origin: VIMEO_EMBED,
|
|
77
|
+
embed: (u) => {
|
|
78
|
+
const seg = segments(u);
|
|
79
|
+
const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
|
|
80
|
+
if (head === void 0 || !/^\d+$/.test(head)) return void 0;
|
|
81
|
+
const hash = token(next ?? u.searchParams.get("h"));
|
|
82
|
+
return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var DAILYMOTION_EMBED = "https://www.dailymotion.com";
|
|
86
|
+
var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
|
|
87
|
+
var DAILYMOTION = {
|
|
88
|
+
origin: DAILYMOTION_EMBED,
|
|
89
|
+
embed: (u) => {
|
|
90
|
+
const seg = segments(u);
|
|
91
|
+
return dailymotion(
|
|
92
|
+
token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var DAI_LY = {
|
|
97
|
+
origin: DAILYMOTION_EMBED,
|
|
98
|
+
embed: (u) => dailymotion(token(segments(u)[0]))
|
|
99
|
+
};
|
|
100
|
+
var LOOM = {
|
|
101
|
+
origin: "https://www.loom.com",
|
|
102
|
+
embed: (u) => {
|
|
103
|
+
const seg = segments(u);
|
|
104
|
+
const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
|
|
105
|
+
return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var EMBEDS = {
|
|
109
|
+
"youtube.com": YOUTUBE,
|
|
110
|
+
"youtube-nocookie.com": YOUTUBE,
|
|
111
|
+
"youtu.be": YOUTU_BE,
|
|
112
|
+
"vimeo.com": VIMEO,
|
|
113
|
+
"dailymotion.com": DAILYMOTION,
|
|
114
|
+
"dai.ly": DAI_LY,
|
|
115
|
+
"loom.com": LOOM
|
|
116
|
+
};
|
|
117
|
+
var EMBED_ORIGINS = [
|
|
118
|
+
...new Set(Object.values(EMBEDS).map((e) => e.origin))
|
|
119
|
+
];
|
|
120
|
+
function embedUrl(url) {
|
|
121
|
+
const host = hostOf(url);
|
|
122
|
+
if (host === null) return void 0;
|
|
123
|
+
let u;
|
|
124
|
+
try {
|
|
125
|
+
u = new URL(url);
|
|
126
|
+
} catch {
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
|
|
130
|
+
const rule = Object.entries(EMBEDS).find(([h]) => host === h || host.endsWith(`.${h}`))?.[1];
|
|
131
|
+
return rule?.embed(u);
|
|
132
|
+
}
|
|
133
|
+
function policyFor(url, prefer) {
|
|
134
|
+
if (isEmbed(url)) return "embed";
|
|
135
|
+
const host = hostOf(url);
|
|
136
|
+
if (host === null || /^data:/i.test(url)) return "bake";
|
|
137
|
+
if (prefer === "link") return "link";
|
|
138
|
+
return FILE_EXT.has(extOf(url)) ? "bake" : "link";
|
|
139
|
+
}
|
|
140
|
+
async function planMedia(assets, fetcher = fetchAsset) {
|
|
141
|
+
const plan = {
|
|
142
|
+
media: [],
|
|
143
|
+
files: {},
|
|
144
|
+
bakedCount: 0,
|
|
145
|
+
bakedBytes: 0,
|
|
146
|
+
demoted: [],
|
|
147
|
+
promoted: []
|
|
148
|
+
};
|
|
149
|
+
for (const asset of assets) {
|
|
150
|
+
const policy = policyFor(asset.url, asset.prefer);
|
|
151
|
+
if (policy !== "bake") {
|
|
152
|
+
if (asset.prefer === "bake") plan.demoted.push(asset.id);
|
|
153
|
+
plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
157
|
+
const got = await fetcher(asset.url);
|
|
158
|
+
const mime = clean(got.mime) ?? asset.mime;
|
|
159
|
+
if (mime === "text/html") {
|
|
160
|
+
plan.demoted.push(asset.id);
|
|
161
|
+
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
165
|
+
plan.files[path2] = got.bytes;
|
|
166
|
+
plan.bakedCount += 1;
|
|
167
|
+
plan.bakedBytes += got.bytes.length;
|
|
168
|
+
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
169
|
+
}
|
|
170
|
+
return plan;
|
|
171
|
+
}
|
|
172
|
+
function mediaSummary(plan) {
|
|
173
|
+
const linked = plan.media.filter((m) => m.policy === "link").length;
|
|
174
|
+
const embedded = plan.media.filter((m) => m.policy === "embed").length;
|
|
175
|
+
const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
|
|
176
|
+
if (linked) parts.push(`${linked} linked`);
|
|
177
|
+
if (embedded) parts.push(`${embedded} embedded`);
|
|
178
|
+
if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
|
|
179
|
+
return parts.join(", ");
|
|
180
|
+
}
|
|
181
|
+
function bakedName(id2, url, mime) {
|
|
182
|
+
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
183
|
+
const hash = createHash("sha256").update(url).digest("hex").slice(0, 8);
|
|
184
|
+
return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
|
|
185
|
+
}
|
|
186
|
+
function extOf(url) {
|
|
187
|
+
const path2 = hostOf(url) === null ? url : new URL(url).pathname;
|
|
188
|
+
const ext = extname(path2).toLowerCase();
|
|
189
|
+
return FILE_EXT.has(ext) ? ext : "";
|
|
190
|
+
}
|
|
191
|
+
var MIME_EXT = {
|
|
192
|
+
"image/png": ".png",
|
|
193
|
+
"image/jpeg": ".jpg",
|
|
194
|
+
"image/gif": ".gif",
|
|
195
|
+
"image/webp": ".webp",
|
|
196
|
+
"image/avif": ".avif",
|
|
197
|
+
"image/svg+xml": ".svg",
|
|
198
|
+
"video/mp4": ".mp4",
|
|
199
|
+
"video/webm": ".webm",
|
|
200
|
+
"audio/mpeg": ".mp3",
|
|
201
|
+
"application/pdf": ".pdf"
|
|
202
|
+
};
|
|
203
|
+
function extFor(mime) {
|
|
204
|
+
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
205
|
+
}
|
|
206
|
+
function hostOf(url) {
|
|
207
|
+
try {
|
|
208
|
+
const u = new URL(url);
|
|
209
|
+
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
210
|
+
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function clean(mime) {
|
|
216
|
+
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
217
|
+
}
|
|
218
|
+
function size(bytes) {
|
|
219
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
220
|
+
const units = ["KB", "MB", "GB"];
|
|
221
|
+
let n3 = bytes / 1024;
|
|
222
|
+
let i = 0;
|
|
223
|
+
while (n3 >= 1024 && i < units.length - 1) {
|
|
224
|
+
n3 /= 1024;
|
|
225
|
+
i += 1;
|
|
226
|
+
}
|
|
227
|
+
return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
|
|
228
|
+
}
|
|
229
|
+
async function fetchAsset(url) {
|
|
230
|
+
if (/^(https?|data):/i.test(url)) {
|
|
231
|
+
const res = await fetch(url);
|
|
232
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
233
|
+
return {
|
|
234
|
+
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
235
|
+
mime: res.headers.get("content-type") ?? void 0
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return { bytes: new Uint8Array(await readFile(url)) };
|
|
239
|
+
}
|
|
240
|
+
|
|
7
241
|
// src/types.ts
|
|
8
242
|
import { z } from "zod";
|
|
9
243
|
var refSchema = z.object({
|
|
@@ -12,9 +246,34 @@ var refSchema = z.object({
|
|
|
12
246
|
});
|
|
13
247
|
var figureSchema = z.object({
|
|
14
248
|
id: z.string(),
|
|
249
|
+
/**
|
|
250
|
+
* WHAT THE ASSET IS. A CLIP IS A FIGURE.
|
|
251
|
+
*
|
|
252
|
+
* A harvested page hands back stills and video in one pass, and the obvious
|
|
253
|
+
* shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
|
|
254
|
+
* costs every layer that already knows what a figure is: the inventory
|
|
255
|
+
* `renderSource` prints, `assertRefsResolve`, the archetypes that take a
|
|
256
|
+
* `figureId`, and the pack. All of them would need a second word for "the
|
|
257
|
+
* thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
|
|
258
|
+
* that a fifth entry would put into every stored plan's schema. A clip is a
|
|
259
|
+
* rectangle with intrinsic pixels and a caption that a beat points at, which
|
|
260
|
+
* is what a figure is; what differs is one branch at emit.
|
|
261
|
+
*
|
|
262
|
+
* DEFAULTED rather than required, and that is the whole point of the field
|
|
263
|
+
* being an enum with a default: every `source.json` written before clips
|
|
264
|
+
* existed parses unchanged and comes back an `image`, which is what it has
|
|
265
|
+
* always been.
|
|
266
|
+
*/
|
|
267
|
+
kind: z.enum(["image", "clip"]).default("image"),
|
|
15
268
|
/** Path relative to the deck's asset directory. */
|
|
16
269
|
src: z.string(),
|
|
17
270
|
caption: z.string(),
|
|
271
|
+
/**
|
|
272
|
+
* The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
|
|
273
|
+
* dimensions, not the poster's. Every fit, crop and leader-line fraction
|
|
274
|
+
* downstream is expressed against this box, so a clip whose poster was
|
|
275
|
+
* letterboxed to another shape would put every annotation in the wrong place.
|
|
276
|
+
*/
|
|
18
277
|
width: z.int().positive(),
|
|
19
278
|
height: z.int().positive(),
|
|
20
279
|
/**
|
|
@@ -32,7 +291,26 @@ var figureSchema = z.object({
|
|
|
32
291
|
*/
|
|
33
292
|
sectionId: z.string().optional(),
|
|
34
293
|
/** The sentence or paragraph that refers to it, verbatim from the document. */
|
|
35
|
-
mention: z.string().optional()
|
|
294
|
+
mention: z.string().optional(),
|
|
295
|
+
// THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
|
|
296
|
+
// is defaulted: an image carries none of them, and a source written before
|
|
297
|
+
// clips existed parses into exactly the object it always did.
|
|
298
|
+
/**
|
|
299
|
+
* The local still that represents the clip — what the deck shows before
|
|
300
|
+
* anyone presses play, and what stands in wherever the video cannot run at
|
|
301
|
+
* all. A clip whose video could not be downloaded is this still and nothing
|
|
302
|
+
* else, so it is the picture the beat is really planned around.
|
|
303
|
+
*/
|
|
304
|
+
poster: z.string().optional(),
|
|
305
|
+
/** How long the clip runs. Seconds, as measured off the file, never guessed. */
|
|
306
|
+
seconds: z.number().positive().optional(),
|
|
307
|
+
/**
|
|
308
|
+
* The page the video lives ON, when the video itself is not a file we can
|
|
309
|
+
* fetch — a player page, an embed, anything whose terms or DRM make the bytes
|
|
310
|
+
* unavailable. Then `poster` is all the deck can show, and this is where a
|
|
311
|
+
* viewer goes to watch the thing. Absent for a clip we hold the file for.
|
|
312
|
+
*/
|
|
313
|
+
href: z.string().optional()
|
|
36
314
|
});
|
|
37
315
|
var equationSchema = z.object({
|
|
38
316
|
id: z.string(),
|
|
@@ -908,8 +1186,8 @@ function clock(seconds) {
|
|
|
908
1186
|
}
|
|
909
1187
|
|
|
910
1188
|
// src/source/fonts.ts
|
|
911
|
-
import { createHash } from "node:crypto";
|
|
912
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1189
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1190
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
913
1191
|
import { join } from "node:path";
|
|
914
1192
|
function familyFor(lang) {
|
|
915
1193
|
const tag = lang.toLowerCase();
|
|
@@ -923,11 +1201,11 @@ async function bundleFont(lang, glyphs, dir) {
|
|
|
923
1201
|
const family = familyFor(lang);
|
|
924
1202
|
if (!family) return null;
|
|
925
1203
|
const text2 = [...new Set(glyphs)].filter((c) => c > " ").sort().join("");
|
|
926
|
-
const stamp = `/* decksmith ${
|
|
1204
|
+
const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
|
|
927
1205
|
${text2}`).digest("hex").slice(0, 16)} */`;
|
|
928
1206
|
await mkdir(dir, { recursive: true });
|
|
929
1207
|
const cssPath = join(dir, "fonts.css");
|
|
930
|
-
const cached = await
|
|
1208
|
+
const cached = await readFile2(cssPath, "utf8").catch(() => "");
|
|
931
1209
|
if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
|
|
932
1210
|
const res = await fetch(
|
|
933
1211
|
`https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
|
|
@@ -1809,11 +2087,11 @@ function unwidow(text2, width, size2, weight = 700, face = "latin") {
|
|
|
1809
2087
|
function wrapTokens(tokens, size2, width, weight, face) {
|
|
1810
2088
|
const lines = [];
|
|
1811
2089
|
let line2 = "";
|
|
1812
|
-
for (const
|
|
1813
|
-
const candidate = line2 ? `${line2} ${
|
|
2090
|
+
for (const token2 of tokens) {
|
|
2091
|
+
const candidate = line2 ? `${line2} ${token2}` : token2;
|
|
1814
2092
|
if (line2 && textWidth(candidate, size2, weight, 0, false, face) > width) {
|
|
1815
2093
|
lines.push(line2);
|
|
1816
|
-
line2 =
|
|
2094
|
+
line2 = token2;
|
|
1817
2095
|
continue;
|
|
1818
2096
|
}
|
|
1819
2097
|
line2 = candidate;
|
|
@@ -2163,6 +2441,11 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2163
2441
|
`annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
|
|
2164
2442
|
);
|
|
2165
2443
|
}
|
|
2444
|
+
if (fig.kind === "clip") {
|
|
2445
|
+
throw new Error(
|
|
2446
|
+
`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`
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2166
2449
|
const crop = p.crop;
|
|
2167
2450
|
const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
|
|
2168
2451
|
const clamp3 = (v) => Math.min(1, Math.max(0, v));
|
|
@@ -2181,7 +2464,7 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2181
2464
|
}
|
|
2182
2465
|
const plan = planFigure(STAGE_W, notes, view, budget2, isPortrait(ctx.format), face);
|
|
2183
2466
|
const stageH = plan.height;
|
|
2184
|
-
const
|
|
2467
|
+
const plate2 = {
|
|
2185
2468
|
x: plan.img.x - PLATE,
|
|
2186
2469
|
y: plan.img.y - PLATE,
|
|
2187
2470
|
w: plan.img.w + 2 * PLATE,
|
|
@@ -2335,7 +2618,7 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2335
2618
|
// Per-scene geometry: the overlay is only correct because the image box is
|
|
2336
2619
|
// stated rather than negotiated with the layout engine.
|
|
2337
2620
|
`#${sid}-stage{width:${STAGE_W}px;height:${n(stageH)}px;margin-top:${STAGE_GAP}px}`,
|
|
2338
|
-
`#${sid}-plate{left:${n(
|
|
2621
|
+
`#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
|
|
2339
2622
|
// `overflow:visible` because a dot sitting on the figure's own edge puts its
|
|
2340
2623
|
// halo outside the viewBox, and a clipped halo reads as a rendering fault.
|
|
2341
2624
|
`#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
|
|
@@ -2791,6 +3074,31 @@ var CLAIM_LH = 1.5;
|
|
|
2791
3074
|
var CLAIM_RULE = 6 + 32;
|
|
2792
3075
|
var BESIDE_COL = 560;
|
|
2793
3076
|
var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
|
|
3077
|
+
function plate(fig, sid, beatId, start) {
|
|
3078
|
+
const img = (src) => ({
|
|
3079
|
+
html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
|
|
3080
|
+
el: "img"
|
|
3081
|
+
});
|
|
3082
|
+
if (fig.kind !== "clip") return img(fig.src);
|
|
3083
|
+
if (fig.href !== void 0) {
|
|
3084
|
+
if (fig.poster === void 0) {
|
|
3085
|
+
throw new Error(
|
|
3086
|
+
`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`
|
|
3087
|
+
);
|
|
3088
|
+
}
|
|
3089
|
+
return img(fig.poster);
|
|
3090
|
+
}
|
|
3091
|
+
if (start === void 0) {
|
|
3092
|
+
throw new Error(
|
|
3093
|
+
`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`
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
|
|
3097
|
+
return {
|
|
3098
|
+
html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
|
|
3099
|
+
el: "video"
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
2794
3102
|
var claimFigure = (beat, ctx) => {
|
|
2795
3103
|
const { sid, theme } = ctx;
|
|
2796
3104
|
const p = beat.params;
|
|
@@ -2830,7 +3138,8 @@ var claimFigure = (beat, ctx) => {
|
|
|
2830
3138
|
);
|
|
2831
3139
|
}
|
|
2832
3140
|
const claim = `<div class="claim" id="${sid}-c">${words(p.claim)}</div>`;
|
|
2833
|
-
const
|
|
3141
|
+
const held = plate(fig, sid, beat.id, ctx.start);
|
|
3142
|
+
const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
|
|
2834
3143
|
const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
|
|
2835
3144
|
const body = tall ? `<div class="cf-stack">${claim}
|
|
2836
3145
|
<div>${figure}
|
|
@@ -2898,12 +3207,22 @@ ${body}`,
|
|
|
2898
3207
|
// Height-capped rather than width-driven: a square figure in the beside
|
|
2899
3208
|
// layout would otherwise be ~970px tall and run off the canvas. The cap is
|
|
2900
3209
|
// the measured remainder, not a constant — see `figMax`.
|
|
2901
|
-
|
|
3210
|
+
//
|
|
3211
|
+
// NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
|
|
3212
|
+
// A rule listing `img, video` would move the bytes of every deck we have
|
|
3213
|
+
// ever built to describe an element almost none of them contain, and a
|
|
3214
|
+
// rule naming only `img` over a clip is a cap that silently does not
|
|
3215
|
+
// apply — the video would render at its natural 1920x1080 and run off the
|
|
3216
|
+
// canvas, which is invariant-5 territory that no gate reads.
|
|
3217
|
+
`.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
|
|
2902
3218
|
`.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
|
|
2903
3219
|
// The image, not its wrapper: the wrapper's entrance already writes
|
|
2904
3220
|
// `transform`. 1.2% of the 550px cap is 3.3px a side, which the wrapper's
|
|
2905
|
-
// 16px padding absorbs — the swell can never reach the canvas edge.
|
|
2906
|
-
|
|
3221
|
+
// 16px padding absorbs — the swell can never reach the canvas edge. Same
|
|
3222
|
+
// reason as the cap above for naming the tag: a drift rule aimed at `img`
|
|
3223
|
+
// over a `<video>` is one ambient rule that animates nothing, and the
|
|
3224
|
+
// slide reads as dead rather than as held.
|
|
3225
|
+
ambient(sid, `-f ${held.el}`, DRIFT)
|
|
2907
3226
|
].join("\n")
|
|
2908
3227
|
};
|
|
2909
3228
|
};
|
|
@@ -3023,8 +3342,8 @@ var dataTable = (beat, ctx) => {
|
|
|
3023
3342
|
p.highlight.forEach((h, i) => {
|
|
3024
3343
|
const index = shown.findIndex((row) => row[0] === h.row);
|
|
3025
3344
|
if (index < 0) {
|
|
3026
|
-
const
|
|
3027
|
-
throw new Error(`data-table ${beat.id}: ${
|
|
3345
|
+
const why3 = 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}`;
|
|
3346
|
+
throw new Error(`data-table ${beat.id}: ${why3}`);
|
|
3028
3347
|
}
|
|
3029
3348
|
const at = settled + 0.3 + i * step;
|
|
3030
3349
|
tl.push(
|
|
@@ -3339,14 +3658,14 @@ var MORPH_SECONDS = 1.6;
|
|
|
3339
3658
|
var equationMorph = (beat, ctx) => {
|
|
3340
3659
|
const { sid, theme } = ctx;
|
|
3341
3660
|
const p = beat.params;
|
|
3342
|
-
const
|
|
3661
|
+
const find3 = (id2) => {
|
|
3343
3662
|
const eq = ctx.source.equations.find((e) => e.id === id2);
|
|
3344
3663
|
if (!eq)
|
|
3345
3664
|
throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
|
|
3346
3665
|
return eq;
|
|
3347
3666
|
};
|
|
3348
|
-
const a =
|
|
3349
|
-
const b =
|
|
3667
|
+
const a = find3(p.fromId);
|
|
3668
|
+
const b = find3(p.toId);
|
|
3350
3669
|
const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
|
|
3351
3670
|
if (both.length === 0) {
|
|
3352
3671
|
throw new Error(
|
|
@@ -3556,10 +3875,10 @@ var grid = (beat, ctx) => {
|
|
|
3556
3875
|
h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
|
|
3557
3876
|
};
|
|
3558
3877
|
};
|
|
3559
|
-
const
|
|
3878
|
+
const boxes3 = p.regions.map(boxOf);
|
|
3560
3879
|
const lx = W - gutter + 40;
|
|
3561
3880
|
const outside = p.regions.map((r, i) => ({ r, i })).filter(({ i }) => inGutter[i]).map(({ r, i }) => {
|
|
3562
|
-
const b =
|
|
3881
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3563
3882
|
const half = lines(r) * LABEL * LH / 2;
|
|
3564
3883
|
return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
|
|
3565
3884
|
}).sort((a, b) => a.y - b.y || a.i - b.i);
|
|
@@ -3577,7 +3896,7 @@ var grid = (beat, ctx) => {
|
|
|
3577
3896
|
const parts = {};
|
|
3578
3897
|
p.regions.forEach((r, i) => {
|
|
3579
3898
|
parts[`rgn${i}`] = r.label;
|
|
3580
|
-
const b =
|
|
3899
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3581
3900
|
const tone2 = theme.tones[r.tone];
|
|
3582
3901
|
rects.push(
|
|
3583
3902
|
`<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, {
|
|
@@ -3685,7 +4004,7 @@ var grid = (beat, ctx) => {
|
|
|
3685
4004
|
tl.push(...dimCells);
|
|
3686
4005
|
p.regions.forEach((_, i) => {
|
|
3687
4006
|
const at = first + i * step;
|
|
3688
|
-
const b =
|
|
4007
|
+
const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
|
|
3689
4008
|
tl.push(
|
|
3690
4009
|
tween(
|
|
3691
4010
|
`#${id(sid, "rgn", i)}`,
|
|
@@ -4085,12 +4404,12 @@ function balance(label, k, face) {
|
|
|
4085
4404
|
if (cur.length > 0) lines.push(cur.join(" "));
|
|
4086
4405
|
return lines;
|
|
4087
4406
|
}
|
|
4088
|
-
function centre(
|
|
4089
|
-
const b =
|
|
4407
|
+
function centre(boxes3, i) {
|
|
4408
|
+
const b = boxes3[i];
|
|
4090
4409
|
return b ? b.x + b.w / 2 : 0;
|
|
4091
4410
|
}
|
|
4092
|
-
function loopLabelWidth(
|
|
4093
|
-
const mid = (centre(
|
|
4411
|
+
function loopLabelWidth(boxes3, from, to, stageW) {
|
|
4412
|
+
const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
|
|
4094
4413
|
return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
|
|
4095
4414
|
}
|
|
4096
4415
|
function widest(lines, face) {
|
|
@@ -4123,11 +4442,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4123
4442
|
let fit = solve(stages, capped, stageW, face);
|
|
4124
4443
|
if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
|
|
4125
4444
|
const size2 = Math.max(MIN_FONT, Math.floor(fit.size));
|
|
4126
|
-
const
|
|
4127
|
-
const innerW = Math.max(size2, (
|
|
4445
|
+
const boxes3 = fit.boxes;
|
|
4446
|
+
const innerW = Math.max(size2, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size2);
|
|
4128
4447
|
const labelLines = stages.map((s) => wrap(s.label, size2, innerW, 600, 0, face));
|
|
4129
4448
|
const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
|
|
4130
|
-
const boxW =
|
|
4449
|
+
const boxW = boxes3[0]?.w ?? width;
|
|
4131
4450
|
const need = Math.max(
|
|
4132
4451
|
MIN_BOX_H,
|
|
4133
4452
|
Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
|
|
@@ -4137,15 +4456,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4137
4456
|
return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
|
|
4138
4457
|
})
|
|
4139
4458
|
);
|
|
4140
|
-
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(
|
|
4459
|
+
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
|
|
4141
4460
|
const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
|
|
4142
4461
|
const boxH = need;
|
|
4143
4462
|
return {
|
|
4144
4463
|
size: size2,
|
|
4145
|
-
boxes,
|
|
4464
|
+
boxes: boxes3,
|
|
4146
4465
|
boxH,
|
|
4147
4466
|
boxX: M,
|
|
4148
|
-
boxW:
|
|
4467
|
+
boxW: boxes3[0]?.w ?? width,
|
|
4149
4468
|
vertical: false,
|
|
4150
4469
|
innerW,
|
|
4151
4470
|
labelLines,
|
|
@@ -4473,6 +4792,11 @@ var splitCompare = (beat, ctx) => {
|
|
|
4473
4792
|
if (fig.width <= 0 || fig.height <= 0) {
|
|
4474
4793
|
throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
|
|
4475
4794
|
}
|
|
4795
|
+
if (fig.kind === "clip") {
|
|
4796
|
+
throw new Error(
|
|
4797
|
+
`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`
|
|
4798
|
+
);
|
|
4799
|
+
}
|
|
4476
4800
|
return fig;
|
|
4477
4801
|
});
|
|
4478
4802
|
sides.forEach((side, i) => {
|
|
@@ -4771,9 +5095,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
|
4771
5095
|
function stackLayout(p, format, face = "latin") {
|
|
4772
5096
|
const stacked = solve2(p, format, false, face);
|
|
4773
5097
|
if (stacked.fits || !p.layers.some((l) => l.note)) return stacked;
|
|
4774
|
-
const
|
|
4775
|
-
if (
|
|
4776
|
-
return
|
|
5098
|
+
const inline2 = solve2(p, format, true, face);
|
|
5099
|
+
if (inline2.fits) return inline2;
|
|
5100
|
+
return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
|
|
4777
5101
|
}
|
|
4778
5102
|
function labelWeight(i, count) {
|
|
4779
5103
|
return i === count - 1 ? 700 : 600;
|
|
@@ -4782,7 +5106,7 @@ function floorFor(p, format) {
|
|
|
4782
5106
|
if (!p.tilt) return MIN_FONT;
|
|
4783
5107
|
return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
|
|
4784
5108
|
}
|
|
4785
|
-
function solve2(p, format,
|
|
5109
|
+
function solve2(p, format, inline2, face) {
|
|
4786
5110
|
const width = contentW(format);
|
|
4787
5111
|
const boxH = contentH(format);
|
|
4788
5112
|
const floor = floorFor(p, format);
|
|
@@ -4791,15 +5115,15 @@ function solve2(p, format, inline, face) {
|
|
|
4791
5115
|
const riseMax = RISE_MAX[k];
|
|
4792
5116
|
const syMax = SY_MAX[k];
|
|
4793
5117
|
const tMax = T_MAX[k];
|
|
4794
|
-
const noteW = (l) =>
|
|
5118
|
+
const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
|
|
4795
5119
|
const want = Math.max(
|
|
4796
5120
|
...p.layers.map(
|
|
4797
5121
|
(l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
|
|
4798
5122
|
)
|
|
4799
5123
|
);
|
|
4800
|
-
const colCap =
|
|
5124
|
+
const colCap = inline2 ? width * 0.56 : width * 0.5;
|
|
4801
5125
|
const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
|
|
4802
|
-
const wide = !
|
|
5126
|
+
const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
|
|
4803
5127
|
const labelX = width - colW;
|
|
4804
5128
|
const labelRoom = Math.min(
|
|
4805
5129
|
...p.layers.map(
|
|
@@ -4814,14 +5138,14 @@ function solve2(p, format, inline, face) {
|
|
|
4814
5138
|
label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
|
|
4815
5139
|
// Inline notes stay on one line by contract — the schema calls a note "one
|
|
4816
5140
|
// short line" — and wrapping one would put its second line under the label.
|
|
4817
|
-
note: l.note === void 0 ? [] :
|
|
5141
|
+
note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
|
|
4818
5142
|
noteW: nw,
|
|
4819
5143
|
labelMaxW
|
|
4820
5144
|
};
|
|
4821
5145
|
});
|
|
4822
5146
|
const blockH = Math.max(
|
|
4823
5147
|
...lines.map(
|
|
4824
|
-
(l) =>
|
|
5148
|
+
(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)
|
|
4825
5149
|
)
|
|
4826
5150
|
);
|
|
4827
5151
|
const pad = Math.max(EDGE2, blockH / 2);
|
|
@@ -4843,7 +5167,7 @@ function solve2(p, format, inline, face) {
|
|
|
4843
5167
|
fits: room >= blockH + 10 && height <= free && wide,
|
|
4844
5168
|
floor,
|
|
4845
5169
|
wide,
|
|
4846
|
-
inline,
|
|
5170
|
+
inline: inline2,
|
|
4847
5171
|
width,
|
|
4848
5172
|
height,
|
|
4849
5173
|
avail,
|
|
@@ -5247,7 +5571,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
|
|
|
5247
5571
|
format,
|
|
5248
5572
|
laid.slides,
|
|
5249
5573
|
runtimeJs,
|
|
5250
|
-
narrationIsland(opts.narration, laid.spoken)
|
|
5574
|
+
narrationIsland(opts.narration, laid.spoken),
|
|
5575
|
+
videoIsland(laid.embeds)
|
|
5251
5576
|
)
|
|
5252
5577
|
};
|
|
5253
5578
|
}
|
|
@@ -5258,17 +5583,20 @@ function planCut(storyboard, source, format, opts = {}) {
|
|
|
5258
5583
|
const seconds = {};
|
|
5259
5584
|
const undrawable = /* @__PURE__ */ new Set();
|
|
5260
5585
|
floor.forEach((beat, i) => {
|
|
5261
|
-
const
|
|
5586
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5262
5587
|
let scene;
|
|
5263
5588
|
try {
|
|
5264
|
-
({ scene } = stageScene(
|
|
5589
|
+
({ scene } = stageScene(
|
|
5590
|
+
emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
|
|
5591
|
+
speed
|
|
5592
|
+
));
|
|
5265
5593
|
} catch (err) {
|
|
5266
5594
|
if (!opts.onBeatError) throw err;
|
|
5267
5595
|
opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
|
|
5268
5596
|
undrawable.add(beat.id);
|
|
5269
5597
|
return;
|
|
5270
5598
|
}
|
|
5271
|
-
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene,
|
|
5599
|
+
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5272
5600
|
});
|
|
5273
5601
|
if (floor.length > 0 && undrawable.size === floor.length) {
|
|
5274
5602
|
throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
|
|
@@ -5301,31 +5629,29 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5301
5629
|
const scenes = [];
|
|
5302
5630
|
const slides = [];
|
|
5303
5631
|
const spoken = {};
|
|
5632
|
+
const embeds = {};
|
|
5304
5633
|
const entered = enteredParts(beats);
|
|
5634
|
+
let at = 0;
|
|
5305
5635
|
const cuts = beats.map((beat, i) => {
|
|
5306
5636
|
const sid = `s${i + 1}`;
|
|
5307
|
-
const ctx = { source, format, theme, sid };
|
|
5308
|
-
const
|
|
5637
|
+
const ctx = { source, format, theme, sid, start: rnd(at) };
|
|
5638
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5309
5639
|
const { scene } = stageScene(emitScene(beat, ctx), speed);
|
|
5310
|
-
const seconds = beatSeconds(beat.seconds * speed, scene,
|
|
5640
|
+
const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5311
5641
|
const inside = entered[i];
|
|
5312
5642
|
const dive = inside ? { t0: rnd(seconds), dur: rnd(MOVE_SECONDS * speed), fade: rnd(FADE_SECONDS * speed) } : void 0;
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
segments,
|
|
5318
|
-
inside,
|
|
5319
|
-
dive,
|
|
5320
|
-
duration: dive ? seconds + diveTail(dive) : seconds
|
|
5321
|
-
};
|
|
5643
|
+
const duration = dive ? seconds + diveTail(dive) : seconds;
|
|
5644
|
+
const start = at;
|
|
5645
|
+
at += duration;
|
|
5646
|
+
return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
|
|
5322
5647
|
});
|
|
5323
|
-
let start = 0;
|
|
5324
5648
|
let builds = false;
|
|
5325
5649
|
const plugins = /* @__PURE__ */ new Set();
|
|
5326
5650
|
cuts.forEach((cut2, i) => {
|
|
5327
|
-
const { beat, sid, dive, inside, duration } = cut2;
|
|
5651
|
+
const { beat, sid, dive, inside, duration, start } = cut2;
|
|
5328
5652
|
if (cut2.segments?.length) spoken[sid] = cut2.segments;
|
|
5653
|
+
const embed = playerEmbed(beat, source);
|
|
5654
|
+
if (embed) embeds[sid] = embed;
|
|
5329
5655
|
const next = cuts[i + 1];
|
|
5330
5656
|
const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
|
|
5331
5657
|
let scene = cut2.scene;
|
|
@@ -5355,7 +5681,6 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5355
5681
|
notes: beat.narration ?? beat.intent,
|
|
5356
5682
|
holds: scene.holds
|
|
5357
5683
|
});
|
|
5358
|
-
start += duration;
|
|
5359
5684
|
});
|
|
5360
5685
|
return {
|
|
5361
5686
|
family,
|
|
@@ -5367,7 +5692,11 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5367
5692
|
scenes,
|
|
5368
5693
|
slides,
|
|
5369
5694
|
spoken,
|
|
5370
|
-
|
|
5695
|
+
embeds,
|
|
5696
|
+
// The clock after the last scene: the sum the map above finished with, which
|
|
5697
|
+
// is the number the second pass used to arrive at by re-adding the same
|
|
5698
|
+
// durations in the same order.
|
|
5699
|
+
total: at,
|
|
5371
5700
|
cut,
|
|
5372
5701
|
builds,
|
|
5373
5702
|
plugins
|
|
@@ -5418,23 +5747,23 @@ function openSeconds(scene) {
|
|
|
5418
5747
|
const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
|
|
5419
5748
|
return rnd(first ?? 0);
|
|
5420
5749
|
}
|
|
5421
|
-
function beatSeconds(authored, scene,
|
|
5422
|
-
if (!
|
|
5750
|
+
function beatSeconds(authored, scene, segments2) {
|
|
5751
|
+
if (!segments2?.length) return authored;
|
|
5423
5752
|
const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
|
|
5424
5753
|
const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
5425
5754
|
(a, b) => a - b
|
|
5426
5755
|
);
|
|
5427
|
-
const ends = speechPlan(openSeconds(scene), usable,
|
|
5756
|
+
const ends = speechPlan(openSeconds(scene), usable, segments2).end;
|
|
5428
5757
|
return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
|
|
5429
5758
|
}
|
|
5430
5759
|
function stageScene(scene, speed) {
|
|
5431
5760
|
const paced = pace(scene, speed);
|
|
5432
5761
|
return { scene: paced, open: openSeconds(paced) };
|
|
5433
5762
|
}
|
|
5434
|
-
function speechPlan(open, holds,
|
|
5763
|
+
function speechPlan(open, holds, segments2) {
|
|
5435
5764
|
const starts = [];
|
|
5436
5765
|
let at = open;
|
|
5437
|
-
for (const [i, segment] of
|
|
5766
|
+
for (const [i, segment] of segments2.entries()) {
|
|
5438
5767
|
const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
|
|
5439
5768
|
if (i > 0) at = Math.max(at, hold);
|
|
5440
5769
|
starts.push(at);
|
|
@@ -5529,9 +5858,9 @@ function narrationIsland(narration, scenes) {
|
|
|
5529
5858
|
voice: narration.voice,
|
|
5530
5859
|
dir: narration.dir,
|
|
5531
5860
|
scenes: Object.fromEntries(
|
|
5532
|
-
Object.entries(scenes).map(([sid,
|
|
5861
|
+
Object.entries(scenes).map(([sid, segments2]) => [
|
|
5533
5862
|
sid,
|
|
5534
|
-
|
|
5863
|
+
segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
|
|
5535
5864
|
])
|
|
5536
5865
|
)
|
|
5537
5866
|
};
|
|
@@ -5541,7 +5870,22 @@ function narrationIsland(narration, scenes) {
|
|
|
5541
5870
|
${json}
|
|
5542
5871
|
</script>`;
|
|
5543
5872
|
}
|
|
5544
|
-
function
|
|
5873
|
+
function playerEmbed(beat, source) {
|
|
5874
|
+
if (beat.archetype !== "claim-figure") return void 0;
|
|
5875
|
+
const fig = source.figures.find((f) => f.id === beat.params.figureId);
|
|
5876
|
+
if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
|
|
5877
|
+
const url = embedUrl(fig.href);
|
|
5878
|
+
return url === void 0 ? void 0 : { url, title: fig.caption };
|
|
5879
|
+
}
|
|
5880
|
+
function videoIsland(clips) {
|
|
5881
|
+
if (Object.keys(clips).length === 0) return "";
|
|
5882
|
+
const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
|
|
5883
|
+
return `
|
|
5884
|
+
<script type="application/decksmith-video+json">
|
|
5885
|
+
${json}
|
|
5886
|
+
</script>`;
|
|
5887
|
+
}
|
|
5888
|
+
function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
|
|
5545
5889
|
return `<!doctype html>
|
|
5546
5890
|
<html lang="${esc(storyboard.lang)}">
|
|
5547
5891
|
<head>
|
|
@@ -5560,7 +5904,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
|
|
|
5560
5904
|
width="${format.width}"
|
|
5561
5905
|
height="${format.height}"
|
|
5562
5906
|
></hyperframes-player>
|
|
5563
|
-
${emitIsland(slides)}${narration}
|
|
5907
|
+
${emitIsland(slides)}${narration}${video}
|
|
5564
5908
|
<script>
|
|
5565
5909
|
${closeSafe(runtimeJs)}
|
|
5566
5910
|
</script>
|
|
@@ -5712,7 +6056,7 @@ function readFragments(html) {
|
|
|
5712
6056
|
return out;
|
|
5713
6057
|
}
|
|
5714
6058
|
function holdsFor(beat, source, format, theme, sid, speed) {
|
|
5715
|
-
const ctx = { source, format, theme, sid };
|
|
6059
|
+
const ctx = { source, format, theme, sid, start: 0 };
|
|
5716
6060
|
const { scene, open } = stageScene(emitScene(beat, ctx), speed);
|
|
5717
6061
|
return {
|
|
5718
6062
|
holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
@@ -5736,15 +6080,15 @@ function assertHoldsAgree(scenes, fragments) {
|
|
|
5736
6080
|
function place(scenes, spoken) {
|
|
5737
6081
|
const out = [];
|
|
5738
6082
|
for (const scene of scenes) {
|
|
5739
|
-
const
|
|
5740
|
-
if (
|
|
6083
|
+
const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
|
|
6084
|
+
if (segments2.length === 0) continue;
|
|
5741
6085
|
if (scene.holds.length === 0) {
|
|
5742
6086
|
throw new Error(
|
|
5743
6087
|
`${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
|
|
5744
6088
|
);
|
|
5745
6089
|
}
|
|
5746
|
-
const { starts } = speechPlan(scene.open, scene.holds,
|
|
5747
|
-
for (const [i, segment] of
|
|
6090
|
+
const { starts } = speechPlan(scene.open, scene.holds, segments2);
|
|
6091
|
+
for (const [i, segment] of segments2.entries()) {
|
|
5748
6092
|
const index = Math.min(segment.stop, scene.holds.length - 1);
|
|
5749
6093
|
out.push({
|
|
5750
6094
|
id: `${scene.id}.${segment.stop}`,
|
|
@@ -5760,9 +6104,9 @@ function place(scenes, spoken) {
|
|
|
5760
6104
|
}
|
|
5761
6105
|
return out;
|
|
5762
6106
|
}
|
|
5763
|
-
function assertFits(scenes,
|
|
6107
|
+
function assertFits(scenes, segments2) {
|
|
5764
6108
|
for (const scene of scenes) {
|
|
5765
|
-
const mine =
|
|
6109
|
+
const mine = segments2.filter((s) => s.scene === scene.id);
|
|
5766
6110
|
if (mine.length === 0) continue;
|
|
5767
6111
|
const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
|
|
5768
6112
|
const need = scene.open + spoken;
|
|
@@ -5806,8 +6150,8 @@ function planTiming(input) {
|
|
|
5806
6150
|
const staging = holdsFor(beat, source, format, theme, scene.id, speed);
|
|
5807
6151
|
scene.holds = staging.holds;
|
|
5808
6152
|
scene.open = staging.open;
|
|
5809
|
-
const
|
|
5810
|
-
if (
|
|
6153
|
+
const segments3 = narration?.beats[beat.id];
|
|
6154
|
+
if (segments3?.length) spoken[scene.id] = segments3;
|
|
5811
6155
|
});
|
|
5812
6156
|
const fragments = readFragments(composition);
|
|
5813
6157
|
if (fragments) {
|
|
@@ -5816,18 +6160,18 @@ function planTiming(input) {
|
|
|
5816
6160
|
fragments
|
|
5817
6161
|
);
|
|
5818
6162
|
}
|
|
5819
|
-
const
|
|
5820
|
-
assertFits(scenes,
|
|
6163
|
+
const segments2 = place(scenes, spoken);
|
|
6164
|
+
assertFits(scenes, segments2);
|
|
5821
6165
|
return {
|
|
5822
6166
|
version: 1,
|
|
5823
6167
|
width: format.width,
|
|
5824
6168
|
height: format.height,
|
|
5825
6169
|
duration: readDuration(composition),
|
|
5826
6170
|
lang: storyboard.lang,
|
|
5827
|
-
audioDir:
|
|
5828
|
-
voice:
|
|
6171
|
+
audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
|
|
6172
|
+
voice: segments2.length > 0 ? narration?.voice ?? "" : "",
|
|
5829
6173
|
scenes,
|
|
5830
|
-
segments
|
|
6174
|
+
segments: segments2
|
|
5831
6175
|
};
|
|
5832
6176
|
}
|
|
5833
6177
|
function framePlan(timing, fps) {
|
|
@@ -5839,12 +6183,12 @@ function framePlan(timing, fps) {
|
|
|
5839
6183
|
for (const scene of timing.scenes) {
|
|
5840
6184
|
const first = f(scene.start);
|
|
5841
6185
|
const last = f(scene.start + scene.duration);
|
|
5842
|
-
const
|
|
5843
|
-
if (
|
|
6186
|
+
const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
|
|
6187
|
+
if (segments2.length === 0) {
|
|
5844
6188
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
5845
6189
|
continue;
|
|
5846
6190
|
}
|
|
5847
|
-
const tail3 =
|
|
6191
|
+
const tail3 = segments2[segments2.length - 1];
|
|
5848
6192
|
const silent = f(tail3.start + tail3.duration);
|
|
5849
6193
|
if (silent > last) {
|
|
5850
6194
|
throw new Error(
|
|
@@ -5852,7 +6196,7 @@ function framePlan(timing, fps) {
|
|
|
5852
6196
|
);
|
|
5853
6197
|
}
|
|
5854
6198
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
5855
|
-
for (const segment of
|
|
6199
|
+
for (const segment of segments2) {
|
|
5856
6200
|
const at = f(segment.start);
|
|
5857
6201
|
audio.push({
|
|
5858
6202
|
id: segment.id,
|
|
@@ -5925,7 +6269,7 @@ function round5(n3) {
|
|
|
5925
6269
|
}
|
|
5926
6270
|
|
|
5927
6271
|
// src/source/markdown.ts
|
|
5928
|
-
import { createHash as
|
|
6272
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5929
6273
|
import remarkGfm from "remark-gfm";
|
|
5930
6274
|
import remarkMath from "remark-math";
|
|
5931
6275
|
import remarkParse from "remark-parse";
|
|
@@ -5977,6 +6321,9 @@ function parseMarkdown(md, opts = {}) {
|
|
|
5977
6321
|
for (const img of images) {
|
|
5978
6322
|
const figure = {
|
|
5979
6323
|
id: `fig${figures.length + 1}`,
|
|
6324
|
+
// Markdown carries no video: an image node is an image. A clip only
|
|
6325
|
+
// ever enters through `harvest`, which sets this itself.
|
|
6326
|
+
kind: "image",
|
|
5980
6327
|
src: img.url,
|
|
5981
6328
|
caption: caption ?? img.alt ?? "",
|
|
5982
6329
|
// 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
|
|
@@ -6012,7 +6359,7 @@ function parseMarkdown(md, opts = {}) {
|
|
|
6012
6359
|
if (mention) p.figure.mention = mention;
|
|
6013
6360
|
}
|
|
6014
6361
|
return sourceSchema.parse({
|
|
6015
|
-
id: opts.id ??
|
|
6362
|
+
id: opts.id ?? createHash3("sha256").update(md).digest("hex").slice(0, 12),
|
|
6016
6363
|
title: title2 || "Untitled",
|
|
6017
6364
|
lang: opts.lang ?? sniffLang(md),
|
|
6018
6365
|
sections,
|
|
@@ -6021,10 +6368,14 @@ function parseMarkdown(md, opts = {}) {
|
|
|
6021
6368
|
tables
|
|
6022
6369
|
});
|
|
6023
6370
|
}
|
|
6371
|
+
var SCRIPT_SHARE = 0.02;
|
|
6024
6372
|
function sniffLang(md) {
|
|
6025
|
-
|
|
6026
|
-
if (
|
|
6027
|
-
|
|
6373
|
+
const total = md.replace(/\s+/g, "").length;
|
|
6374
|
+
if (total === 0) return "en";
|
|
6375
|
+
const share2 = (re) => (md.match(re)?.length ?? 0) / total;
|
|
6376
|
+
if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
|
|
6377
|
+
if (share2(/[-ヿ]/g) >= SCRIPT_SHARE) return "ja";
|
|
6378
|
+
if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
|
|
6028
6379
|
return "en";
|
|
6029
6380
|
}
|
|
6030
6381
|
function onlyImages(kids) {
|
|
@@ -6045,7 +6396,11 @@ function mentionOf(caption, prose, at, opened) {
|
|
|
6045
6396
|
const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
|
|
6046
6397
|
if (found) return found.text;
|
|
6047
6398
|
}
|
|
6048
|
-
|
|
6399
|
+
const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
|
|
6400
|
+
return near !== void 0 && isSentence(near) ? near : void 0;
|
|
6401
|
+
}
|
|
6402
|
+
function isSentence(text2) {
|
|
6403
|
+
return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
|
|
6049
6404
|
}
|
|
6050
6405
|
function figureName(caption) {
|
|
6051
6406
|
const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
|
|
@@ -6105,76 +6460,2119 @@ function textOf(node) {
|
|
|
6105
6460
|
}
|
|
6106
6461
|
}
|
|
6107
6462
|
|
|
6108
|
-
// src/source/
|
|
6109
|
-
import { createHash as
|
|
6110
|
-
import { mkdir as
|
|
6111
|
-
import {
|
|
6112
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
}
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
}
|
|
6135
|
-
|
|
6463
|
+
// src/source/harvest.ts
|
|
6464
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
6465
|
+
import { copyFile, mkdir as mkdir4, rm as rm2, stat, writeFile as writeFile4 } from "node:fs/promises";
|
|
6466
|
+
import { basename as basename2, join as join4, resolve } from "node:path";
|
|
6467
|
+
|
|
6468
|
+
// src/net/fetch.ts
|
|
6469
|
+
import { lookup as resolveHost } from "node:dns/promises";
|
|
6470
|
+
import { request as httpRequest } from "node:http";
|
|
6471
|
+
import { request as httpsRequest } from "node:https";
|
|
6472
|
+
import { isIP } from "node:net";
|
|
6473
|
+
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
|
6474
|
+
|
|
6475
|
+
// src/version.ts
|
|
6476
|
+
import { createRequire } from "node:module";
|
|
6477
|
+
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
6478
|
+
|
|
6479
|
+
// src/net/fetch.ts
|
|
6480
|
+
var BLOCKED_V4 = [
|
|
6481
|
+
// "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
|
|
6482
|
+
{ re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
|
|
6483
|
+
{ re: /^10\./, why: "a private address (10.0.0.0/8)" },
|
|
6484
|
+
// NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
|
|
6485
|
+
// on a host that has one, the whole /10 is the neighbourhood.
|
|
6486
|
+
{
|
|
6487
|
+
re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
6488
|
+
why: "carrier-grade NAT space (100.64.0.0/10)"
|
|
6489
|
+
},
|
|
6490
|
+
{ re: /^127\./, why: "loopback (127.0.0.0/8)" },
|
|
6491
|
+
{ re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
|
|
6492
|
+
{ re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
|
|
6493
|
+
// NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
|
|
6494
|
+
// friends. Not routable on the public internet, so a URL pointing here is
|
|
6495
|
+
// pointing at something local.
|
|
6496
|
+
{ re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
|
|
6497
|
+
{ re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
|
|
6498
|
+
// NEW: benchmarking. Reserved for test equipment, and some networks route it
|
|
6499
|
+
// internally precisely because it will never collide with anything real.
|
|
6500
|
+
{ re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
|
|
6501
|
+
];
|
|
6502
|
+
var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
|
|
6503
|
+
function isBlockedAddress(ip) {
|
|
6504
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6505
|
+
const kind = isIP(bare);
|
|
6506
|
+
if (kind === 0) return `not an IP address (${ip})`;
|
|
6507
|
+
if (kind === 4) {
|
|
6508
|
+
for (const { re, why: why3 } of BLOCKED_V4) if (re.test(bare)) return why3;
|
|
6509
|
+
return null;
|
|
6136
6510
|
}
|
|
6137
|
-
|
|
6138
|
-
return
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6511
|
+
const groups = expandV6(bare);
|
|
6512
|
+
if (groups === null) return `not an IP address (${ip})`;
|
|
6513
|
+
if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
|
|
6514
|
+
if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
|
|
6515
|
+
const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
|
|
6516
|
+
if (embedded) {
|
|
6517
|
+
const hi = groups[6] ?? 0;
|
|
6518
|
+
const lo = groups[7] ?? 0;
|
|
6519
|
+
return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
6520
|
+
}
|
|
6521
|
+
const top = groups[0] ?? 0;
|
|
6522
|
+
if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
|
|
6523
|
+
if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
|
|
6524
|
+
return null;
|
|
6147
6525
|
}
|
|
6148
|
-
function
|
|
6149
|
-
let
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6526
|
+
function expandV6(v6) {
|
|
6527
|
+
let text2 = v6;
|
|
6528
|
+
const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
|
|
6529
|
+
if (quad?.[1]) {
|
|
6530
|
+
const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
|
|
6531
|
+
const head = text2.slice(0, text2.length - quad[1].length);
|
|
6532
|
+
text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
6533
|
+
}
|
|
6534
|
+
const halves = text2.split("::");
|
|
6535
|
+
if (halves.length > 2) return null;
|
|
6536
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
6537
|
+
const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
6538
|
+
const fill = 8 - left.length - (right?.length ?? 0);
|
|
6539
|
+
if (right !== null && fill < 0) return null;
|
|
6540
|
+
const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
|
|
6541
|
+
if (parts.length !== 8) return null;
|
|
6542
|
+
const groups = parts.map((p) => Number.parseInt(p, 16));
|
|
6543
|
+
return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
|
|
6544
|
+
}
|
|
6545
|
+
function isLoopback(ip) {
|
|
6546
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6547
|
+
return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
|
|
6548
|
+
}
|
|
6549
|
+
var MAX_REDIRECTS = 5;
|
|
6550
|
+
var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
|
|
6551
|
+
var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
6552
|
+
var CREDENTIALS = ["authorization", "cookie"];
|
|
6553
|
+
async function fetchGuarded(url, opts) {
|
|
6554
|
+
const signal = AbortSignal.timeout(opts.timeoutMs);
|
|
6555
|
+
let target;
|
|
6556
|
+
try {
|
|
6557
|
+
target = new URL(url);
|
|
6558
|
+
} catch {
|
|
6559
|
+
throw new Error(`refusing ${url}: not a URL. Give an absolute http:// or https:// URL.`);
|
|
6560
|
+
}
|
|
6561
|
+
let headers = lowercased(opts.headers);
|
|
6562
|
+
for (let hop = 0; ; hop++) {
|
|
6563
|
+
if (hop > MAX_REDIRECTS) {
|
|
6564
|
+
throw new Error(
|
|
6565
|
+
`refusing ${url}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
|
|
6566
|
+
);
|
|
6154
6567
|
}
|
|
6155
|
-
const
|
|
6156
|
-
|
|
6157
|
-
if (
|
|
6158
|
-
|
|
6568
|
+
const res = await send(url, target, headers, signal, opts);
|
|
6569
|
+
const status = res.statusCode ?? 0;
|
|
6570
|
+
if (REDIRECTS.has(status)) {
|
|
6571
|
+
const location = res.headers.location;
|
|
6572
|
+
res.destroy();
|
|
6573
|
+
if (location === void 0) {
|
|
6574
|
+
throw new Error(`refusing ${url}: HTTP ${status} from ${target.href} with no Location.`);
|
|
6575
|
+
}
|
|
6576
|
+
let next;
|
|
6577
|
+
try {
|
|
6578
|
+
next = new URL(location, target);
|
|
6579
|
+
} catch {
|
|
6580
|
+
throw new Error(
|
|
6581
|
+
`refusing ${url}: HTTP ${status} to an unparseable Location (${location}).`
|
|
6582
|
+
);
|
|
6583
|
+
}
|
|
6584
|
+
const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
|
|
6585
|
+
if (!sameOrigin) headers = withoutCredentials(headers);
|
|
6586
|
+
target = next;
|
|
6159
6587
|
continue;
|
|
6160
6588
|
}
|
|
6161
|
-
if (
|
|
6162
|
-
|
|
6163
|
-
|
|
6589
|
+
if (status < 200 || status >= 300) {
|
|
6590
|
+
res.destroy();
|
|
6591
|
+
throw new Error(`${target.href}: HTTP ${status}`);
|
|
6164
6592
|
}
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6593
|
+
const contentType = (res.headers["content-type"] ?? "").trim();
|
|
6594
|
+
const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
6595
|
+
if (opts.accept && media.match(opts.accept) === null) {
|
|
6596
|
+
res.destroy();
|
|
6597
|
+
throw new Error(
|
|
6598
|
+
`refusing ${url}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
|
|
6599
|
+
);
|
|
6600
|
+
}
|
|
6601
|
+
const raw2 = await readCapped(res, opts.maxBytes, url, signal, opts.timeoutMs);
|
|
6602
|
+
const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url);
|
|
6603
|
+
return { bytes, contentType, url: target.href };
|
|
6604
|
+
}
|
|
6605
|
+
}
|
|
6606
|
+
async function send(url, target, headers, signal, opts) {
|
|
6607
|
+
const secure = target.protocol === "https:";
|
|
6608
|
+
if (!secure && target.protocol !== "http:") {
|
|
6609
|
+
throw new Error(
|
|
6610
|
+
`refusing ${url}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
|
|
6611
|
+
);
|
|
6612
|
+
}
|
|
6613
|
+
const port = Number(target.port || (secure ? 443 : 80));
|
|
6614
|
+
if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
|
|
6615
|
+
throw new Error(
|
|
6616
|
+
`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.`
|
|
6617
|
+
);
|
|
6618
|
+
}
|
|
6619
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
6620
|
+
let addresses;
|
|
6621
|
+
try {
|
|
6622
|
+
addresses = await Promise.race([resolveHost(host, { all: true }), rejectsOnAbort(signal)]);
|
|
6623
|
+
} catch {
|
|
6624
|
+
if (signal.aborted) throw timedOut(url, opts.timeoutMs);
|
|
6625
|
+
throw new Error(`refusing ${url}: ${host} does not resolve.`);
|
|
6626
|
+
}
|
|
6627
|
+
const pinned = addresses[0];
|
|
6628
|
+
if (pinned === void 0) throw new Error(`refusing ${url}: ${host} does not resolve.`);
|
|
6629
|
+
for (const { address } of addresses) {
|
|
6630
|
+
const why3 = isBlockedAddress(address);
|
|
6631
|
+
if (why3 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
|
|
6632
|
+
throw new Error(
|
|
6633
|
+
`refusing ${url}: ${host} resolves to ${address}, which is ${why3}. Host the file somewhere this server can reach from the public internet.`
|
|
6634
|
+
);
|
|
6635
|
+
}
|
|
6636
|
+
}
|
|
6637
|
+
const options = {
|
|
6638
|
+
hostname: host,
|
|
6639
|
+
port,
|
|
6640
|
+
path: `${target.pathname}${target.search}`,
|
|
6641
|
+
method: "GET",
|
|
6642
|
+
// IDENTIFY OURSELVES, AND ACCEPT HTML.
|
|
6643
|
+
//
|
|
6644
|
+
// Measured against the first real page this was ever pointed at: Wikipedia
|
|
6645
|
+
// answers a request with no `user-agent` with a bare 403, and so do a great
|
|
6646
|
+
// many sites behind a CDN. A fetcher that will not say who it is looks
|
|
6647
|
+
// exactly like a scraper worth blocking, and the failure arrives as an
|
|
6648
|
+
// HTTP status with nothing in it to explain itself. Both headers are
|
|
6649
|
+
// DEFAULTS rather than overrides — a caller that sets either wins, because
|
|
6650
|
+
// the spread below them is the caller's.
|
|
6651
|
+
headers: {
|
|
6652
|
+
"user-agent": USER_AGENT,
|
|
6653
|
+
accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
|
|
6654
|
+
...headers,
|
|
6655
|
+
"accept-encoding": "gzip, br"
|
|
6656
|
+
},
|
|
6657
|
+
signal,
|
|
6658
|
+
// THE PIN. Without this line every check above is advisory: `dns.lookup`
|
|
6659
|
+
// would run a second time inside the connect and could answer differently.
|
|
6660
|
+
lookup: pin(pinned.address, pinned.family),
|
|
6661
|
+
// No connection pool. This is a one-shot against a stranger's host, and a
|
|
6662
|
+
// pooled socket outlives the call that validated it — nothing should be able
|
|
6663
|
+
// to inherit a connection this module opened.
|
|
6664
|
+
agent: false,
|
|
6665
|
+
// Say out loud which name the certificate has to match. Node derives this
|
|
6666
|
+
// from `hostname` today, but the pin means the socket is opened to a bare
|
|
6667
|
+
// address, and a future refactor that stops setting `hostname` would turn
|
|
6668
|
+
// verification off silently rather than fail.
|
|
6669
|
+
...secure && isIP(host) === 0 ? { servername: host } : {}
|
|
6670
|
+
};
|
|
6671
|
+
try {
|
|
6672
|
+
return await new Promise((resolve6, reject) => {
|
|
6673
|
+
const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
|
|
6674
|
+
req.on("error", reject);
|
|
6675
|
+
req.end();
|
|
6676
|
+
});
|
|
6677
|
+
} catch (err) {
|
|
6678
|
+
if (signal.aborted) throw timedOut(url, opts.timeoutMs);
|
|
6679
|
+
throw new Error(`refusing ${url}: ${target.href} could not be reached (${message(err)}).`);
|
|
6680
|
+
}
|
|
6681
|
+
}
|
|
6682
|
+
function pin(address, family) {
|
|
6683
|
+
return (_hostname, options, callback) => {
|
|
6684
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
6685
|
+
else callback(null, address, family);
|
|
6686
|
+
};
|
|
6687
|
+
}
|
|
6688
|
+
async function readCapped(res, maxBytes, url, signal, timeoutMs) {
|
|
6689
|
+
const chunks = [];
|
|
6690
|
+
let total = 0;
|
|
6691
|
+
let oversize = false;
|
|
6692
|
+
try {
|
|
6693
|
+
for await (const chunk of res) {
|
|
6694
|
+
total += chunk.length;
|
|
6695
|
+
if (total > maxBytes) {
|
|
6696
|
+
oversize = true;
|
|
6697
|
+
res.destroy();
|
|
6698
|
+
break;
|
|
6699
|
+
}
|
|
6700
|
+
chunks.push(chunk);
|
|
6701
|
+
}
|
|
6702
|
+
} catch (err) {
|
|
6703
|
+
if (signal.aborted) throw timedOut(url, timeoutMs);
|
|
6704
|
+
throw new Error(`refusing ${url}: the body stopped arriving (${message(err)}).`);
|
|
6705
|
+
}
|
|
6706
|
+
if (oversize) {
|
|
6707
|
+
throw new Error(
|
|
6708
|
+
`refusing ${url}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
|
|
6709
|
+
);
|
|
6710
|
+
}
|
|
6711
|
+
return Buffer.concat(chunks);
|
|
6712
|
+
}
|
|
6713
|
+
function decode(raw2, header, maxBytes, url) {
|
|
6714
|
+
const encoding = (header ?? "").trim().toLowerCase();
|
|
6715
|
+
if (encoding === "" || encoding === "identity") return raw2;
|
|
6716
|
+
try {
|
|
6717
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
6718
|
+
return gunzipSync(raw2, { maxOutputLength: maxBytes });
|
|
6719
|
+
}
|
|
6720
|
+
if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
|
|
6721
|
+
} catch (err) {
|
|
6722
|
+
throw new Error(
|
|
6723
|
+
`refusing ${url}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
|
|
6724
|
+
);
|
|
6725
|
+
}
|
|
6726
|
+
throw new Error(
|
|
6727
|
+
`refusing ${url}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
|
|
6728
|
+
);
|
|
6729
|
+
}
|
|
6730
|
+
function lowercased(headers) {
|
|
6731
|
+
const out = {};
|
|
6732
|
+
for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
|
|
6733
|
+
return out;
|
|
6734
|
+
}
|
|
6735
|
+
function withoutCredentials(headers) {
|
|
6736
|
+
const out = { ...headers };
|
|
6737
|
+
for (const name of CREDENTIALS) delete out[name];
|
|
6738
|
+
return out;
|
|
6739
|
+
}
|
|
6740
|
+
function rejectsOnAbort(signal) {
|
|
6741
|
+
return new Promise((_, reject) => {
|
|
6742
|
+
if (signal.aborted) reject(signal.reason);
|
|
6743
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
6744
|
+
});
|
|
6745
|
+
}
|
|
6746
|
+
function timedOut(url, timeoutMs) {
|
|
6747
|
+
return new Error(
|
|
6748
|
+
`refusing ${url}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
|
|
6749
|
+
);
|
|
6750
|
+
}
|
|
6751
|
+
function message(err) {
|
|
6752
|
+
return err instanceof Error ? err.message : String(err);
|
|
6753
|
+
}
|
|
6754
|
+
|
|
6755
|
+
// src/render/capture.ts
|
|
6756
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
6757
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
6758
|
+
import { homedir } from "node:os";
|
|
6759
|
+
import { join as join2 } from "node:path";
|
|
6760
|
+
import { pathToFileURL } from "node:url";
|
|
6761
|
+
async function chromePath(need = "open the deck with") {
|
|
6762
|
+
const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
|
|
6763
|
+
if (explicit) return explicit;
|
|
6764
|
+
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
6765
|
+
const cacheDir = process.env.PUPPETEER_CACHE_DIR || join2(homedir(), ".cache", "puppeteer");
|
|
6766
|
+
const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
|
|
6767
|
+
const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
|
|
6768
|
+
if (found) return found.executablePath;
|
|
6769
|
+
throw new Error(
|
|
6770
|
+
`no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
|
|
6771
|
+
);
|
|
6772
|
+
}
|
|
6773
|
+
function runtimePath() {
|
|
6774
|
+
return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
|
|
6775
|
+
}
|
|
6776
|
+
function renderSeek(t2) {
|
|
6777
|
+
const player = window.__player;
|
|
6778
|
+
player.renderSeek(t2, { suppressEvents: true });
|
|
6779
|
+
}
|
|
6780
|
+
async function openDeck(dir, opts = {}) {
|
|
6781
|
+
const index = join2(dir, "index.html");
|
|
6782
|
+
const html = await readFile3(index, "utf8").catch(() => null);
|
|
6783
|
+
if (html === null) throw new Error(`no index.html in ${dir}`);
|
|
6784
|
+
const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
|
|
6785
|
+
const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
|
|
6786
|
+
if (!width || !height) throw new Error("index.html declares no canvas size");
|
|
6787
|
+
const timeout = opts.timeoutMs ?? 6e4;
|
|
6788
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
6789
|
+
const browser = await puppeteer.launch({
|
|
6790
|
+
executablePath: await chromePath(),
|
|
6791
|
+
headless: true,
|
|
6792
|
+
// A retina host would otherwise hand back a 2x frame, whose clip is not the
|
|
6793
|
+
// renderer's.
|
|
6794
|
+
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
6795
|
+
});
|
|
6796
|
+
try {
|
|
6797
|
+
const page = await browser.newPage();
|
|
6798
|
+
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
|
|
6799
|
+
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
|
6800
|
+
await page.goto(pathToFileURL(index).href, { waitUntil: "load", timeout });
|
|
6801
|
+
await page.addScriptTag({ path: runtimePath() });
|
|
6802
|
+
await page.waitForFunction(
|
|
6803
|
+
// The composition registers a paused timeline per scene and a spanning
|
|
6804
|
+
// `main` that carries no motion, so seeking `main` directly moves nothing.
|
|
6805
|
+
// Only the runtime knows the per-scene offsets; wait for it.
|
|
6806
|
+
"typeof window.__player?.renderSeek === 'function'",
|
|
6807
|
+
{ timeout }
|
|
6808
|
+
);
|
|
6809
|
+
await page.evaluate(() => document.fonts.ready);
|
|
6810
|
+
const cdp = await page.createCDPSession();
|
|
6811
|
+
return {
|
|
6812
|
+
width,
|
|
6813
|
+
height,
|
|
6814
|
+
page,
|
|
6815
|
+
seek: (t2) => page.evaluate(renderSeek, t2),
|
|
6816
|
+
shoot: async () => {
|
|
6817
|
+
const shot = await cdp.send("Page.captureScreenshot", {
|
|
6818
|
+
format: "png",
|
|
6819
|
+
fromSurface: true,
|
|
6820
|
+
captureBeyondViewport: false,
|
|
6821
|
+
clip: { x: 0, y: 0, width, height, scale: 1 }
|
|
6822
|
+
});
|
|
6823
|
+
return Buffer.from(shot.data, "base64");
|
|
6824
|
+
},
|
|
6825
|
+
close: () => browser.close()
|
|
6826
|
+
};
|
|
6827
|
+
} catch (err) {
|
|
6828
|
+
await browser.close().catch(() => {
|
|
6829
|
+
});
|
|
6830
|
+
throw err;
|
|
6831
|
+
}
|
|
6832
|
+
}
|
|
6833
|
+
|
|
6834
|
+
// src/source/assets.ts
|
|
6835
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
6836
|
+
import { mkdir as mkdir3, readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
6837
|
+
import { extname as extname2, join as join3 } from "node:path";
|
|
6838
|
+
import { z as z2 } from "zod";
|
|
6839
|
+
async function fetchFigures(source, dir, warnings) {
|
|
6840
|
+
const drops = warnings ?? [];
|
|
6841
|
+
await mkdir3(dir, { recursive: true });
|
|
6842
|
+
const cached = await readdir(dir).catch(() => []);
|
|
6843
|
+
const figures = [];
|
|
6844
|
+
for (const figure of source.figures) {
|
|
6845
|
+
if (figure.kind === "clip") {
|
|
6846
|
+
figures.push(figure);
|
|
6847
|
+
continue;
|
|
6848
|
+
}
|
|
6849
|
+
try {
|
|
6850
|
+
figures.push(figureSchema.parse(await localize(figure, dir, cached)));
|
|
6851
|
+
} catch (err) {
|
|
6852
|
+
drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
|
|
6856
|
+
const parsed = sourceSchema.safeParse({ ...source, figures });
|
|
6857
|
+
if (!parsed.success) {
|
|
6858
|
+
throw new Error(
|
|
6859
|
+
`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.`
|
|
6860
|
+
);
|
|
6861
|
+
}
|
|
6862
|
+
return parsed.data;
|
|
6863
|
+
}
|
|
6864
|
+
async function localize(figure, dir, cached) {
|
|
6865
|
+
const stem = assetStem(figure.id, figure.src);
|
|
6866
|
+
const hit = cached.find((name) => name.startsWith(`${stem}.`));
|
|
6867
|
+
const bytes = hit ? await readFile4(join3(dir, hit)) : await load(figure.src);
|
|
6868
|
+
const size2 = imageSize(bytes);
|
|
6869
|
+
if (hit) return { ...figure, src: hit, ...size2 };
|
|
6870
|
+
const src = `${stem}${assetExt(bytes, figure.src)}`;
|
|
6871
|
+
await writeFile3(join3(dir, src), bytes);
|
|
6872
|
+
cached.push(src);
|
|
6873
|
+
return { ...figure, src, ...size2 };
|
|
6874
|
+
}
|
|
6875
|
+
function assetStem(id2, src) {
|
|
6876
|
+
const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
|
|
6877
|
+
return `${readable}-${createHash4("sha256").update(src).digest("hex").slice(0, 8)}`;
|
|
6878
|
+
}
|
|
6879
|
+
var EXT = {
|
|
6880
|
+
png: ".png",
|
|
6881
|
+
jpeg: ".jpg",
|
|
6882
|
+
gif: ".gif",
|
|
6883
|
+
webp: ".webp",
|
|
6884
|
+
avif: ".avif",
|
|
6885
|
+
svg: ".svg"
|
|
6886
|
+
};
|
|
6887
|
+
function assetExt(bytes, src) {
|
|
6888
|
+
const format = sniffFormat(bytes);
|
|
6889
|
+
if (format) return EXT[format];
|
|
6890
|
+
const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
6891
|
+
return fromUrl || ".img";
|
|
6892
|
+
}
|
|
6893
|
+
var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6894
|
+
var FIGURE_TIMEOUT_MS = 2e4;
|
|
6895
|
+
async function load(src) {
|
|
6896
|
+
if (!/^https?:/i.test(src)) return readFile4(src);
|
|
6897
|
+
const got = await fetchGuarded(src, {
|
|
6898
|
+
maxBytes: FIGURE_MAX_BYTES,
|
|
6899
|
+
timeoutMs: FIGURE_TIMEOUT_MS
|
|
6900
|
+
});
|
|
6901
|
+
return got.bytes;
|
|
6902
|
+
}
|
|
6903
|
+
function reason(err) {
|
|
6904
|
+
if (err instanceof z2.ZodError)
|
|
6905
|
+
return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
6906
|
+
return err instanceof Error ? err.message : String(err);
|
|
6907
|
+
}
|
|
6908
|
+
var SVG_WINDOW = 64 * 1024;
|
|
6909
|
+
function sniffFormat(b) {
|
|
6910
|
+
if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
|
|
6911
|
+
return "png";
|
|
6912
|
+
if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
|
|
6913
|
+
if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
|
|
6914
|
+
if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
|
|
6915
|
+
return "webp";
|
|
6916
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
|
|
6917
|
+
if (isSvg(b)) return "svg";
|
|
6918
|
+
return void 0;
|
|
6919
|
+
}
|
|
6920
|
+
function isAvifBrand(b) {
|
|
6921
|
+
const declared = b.readUInt32BE(0);
|
|
6922
|
+
const end = Math.min(declared >= 16 ? declared : b.length, b.length);
|
|
6923
|
+
const brand = (at) => b.toString("latin1", at, at + 4);
|
|
6924
|
+
if (brand(8) === "avif" || brand(8) === "avis") return true;
|
|
6925
|
+
for (let i = 16; i + 4 <= end; i += 4)
|
|
6926
|
+
if (brand(i) === "avif" || brand(i) === "avis") return true;
|
|
6927
|
+
return false;
|
|
6928
|
+
}
|
|
6929
|
+
function isSvg(b) {
|
|
6930
|
+
const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
|
|
6931
|
+
let i = 0;
|
|
6932
|
+
for (; ; ) {
|
|
6933
|
+
while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
|
|
6934
|
+
const comment = text2.startsWith("<!--", i);
|
|
6935
|
+
if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
|
|
6936
|
+
const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
|
|
6937
|
+
if (end <= 0) return false;
|
|
6938
|
+
i = end;
|
|
6939
|
+
continue;
|
|
6940
|
+
}
|
|
6941
|
+
return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
|
|
6942
|
+
}
|
|
6943
|
+
}
|
|
6944
|
+
function imageSize(b) {
|
|
6945
|
+
const format = sniffFormat(b);
|
|
6946
|
+
const raw2 = measure2(b, format);
|
|
6947
|
+
const width = Math.round(raw2.width);
|
|
6948
|
+
const height = Math.round(raw2.height);
|
|
6949
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
|
|
6950
|
+
throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
|
|
6951
|
+
return { width, height };
|
|
6952
|
+
}
|
|
6953
|
+
function measure2(b, format) {
|
|
6954
|
+
switch (format) {
|
|
6955
|
+
case "png":
|
|
6956
|
+
return pngSize(b);
|
|
6957
|
+
case "gif":
|
|
6958
|
+
return gifSize(b);
|
|
6959
|
+
case "jpeg":
|
|
6960
|
+
return jpegSize(b);
|
|
6961
|
+
case "webp":
|
|
6962
|
+
return webpSize(b);
|
|
6963
|
+
case "avif":
|
|
6964
|
+
return avifSize(b);
|
|
6965
|
+
case "svg":
|
|
6966
|
+
return svgFigureSize(b);
|
|
6967
|
+
default:
|
|
6968
|
+
throw new Error(
|
|
6969
|
+
`unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
|
|
6970
|
+
);
|
|
6971
|
+
}
|
|
6972
|
+
}
|
|
6973
|
+
function describe(b) {
|
|
6974
|
+
if (b.length === 0) return "an empty file";
|
|
6975
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
|
|
6976
|
+
return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
|
|
6977
|
+
const head = b.toString("latin1", 0, Math.min(b.length, 64));
|
|
6978
|
+
if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
|
|
6979
|
+
return "an HTML page \u2014 the URL answered with a page, not a picture";
|
|
6980
|
+
if (head.startsWith("%PDF")) return "a PDF";
|
|
6981
|
+
const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
|
|
6982
|
+
return `${b.length} bytes beginning ${hex}`;
|
|
6983
|
+
}
|
|
6984
|
+
function pngSize(b) {
|
|
6985
|
+
if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
|
|
6986
|
+
if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
|
|
6987
|
+
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
|
6988
|
+
}
|
|
6989
|
+
function gifSize(b) {
|
|
6990
|
+
if (b.length < 10)
|
|
6991
|
+
throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
|
|
6992
|
+
return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
|
|
6993
|
+
}
|
|
6994
|
+
function jpegSize(b) {
|
|
6995
|
+
let i = 2;
|
|
6996
|
+
while (i + 9 < b.length) {
|
|
6997
|
+
if (b[i] !== 255) {
|
|
6998
|
+
i++;
|
|
6999
|
+
continue;
|
|
7000
|
+
}
|
|
7001
|
+
const marker = b[i + 1];
|
|
7002
|
+
if (marker === void 0) break;
|
|
7003
|
+
if (marker === 255) {
|
|
7004
|
+
i++;
|
|
7005
|
+
continue;
|
|
7006
|
+
}
|
|
7007
|
+
if (marker === 1 || marker >= 208 && marker <= 217) {
|
|
7008
|
+
i += 2;
|
|
7009
|
+
continue;
|
|
7010
|
+
}
|
|
7011
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
|
|
7012
|
+
return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
|
|
7013
|
+
i += 2 + b.readUInt16BE(i + 2);
|
|
7014
|
+
}
|
|
7015
|
+
throw new Error("JPEG has no SOF segment");
|
|
7016
|
+
}
|
|
7017
|
+
function webpSize(b) {
|
|
7018
|
+
const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
|
|
7019
|
+
if (chunk === "VP8 ") {
|
|
7020
|
+
if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
|
|
7021
|
+
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
|
|
7022
|
+
throw new Error("WebP VP8 chunk has no keyframe sync code");
|
|
7023
|
+
return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
|
|
7024
|
+
}
|
|
7025
|
+
if (chunk === "VP8L") {
|
|
7026
|
+
if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
|
|
7027
|
+
if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
|
|
7028
|
+
const bits = b.readUInt32LE(21);
|
|
7029
|
+
return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
|
|
7030
|
+
}
|
|
7031
|
+
if (chunk === "VP8X") {
|
|
7032
|
+
if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
|
|
7033
|
+
return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
|
|
7034
|
+
}
|
|
7035
|
+
throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
|
|
7036
|
+
}
|
|
7037
|
+
function avifSize(b) {
|
|
7038
|
+
const meta = find(b, 0, b.length, "meta");
|
|
7039
|
+
if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
|
|
7040
|
+
const iprp = find(b, meta.start + 4, meta.end, "iprp");
|
|
7041
|
+
const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
|
|
7042
|
+
if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
|
|
7043
|
+
let best = { width: 0, height: 0 };
|
|
7044
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
7045
|
+
if (box.type !== "ispe" || box.end - box.start < 12) continue;
|
|
7046
|
+
const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
|
|
7047
|
+
if (found.width * found.height > best.width * best.height) best = found;
|
|
7048
|
+
}
|
|
7049
|
+
if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
|
|
7050
|
+
return cleanAperture(b, ipco, best) ?? best;
|
|
7051
|
+
}
|
|
7052
|
+
function cleanAperture(b, ipco, ispe) {
|
|
7053
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
7054
|
+
if (box.type !== "clap" || box.end - box.start < 16) continue;
|
|
7055
|
+
const at = (offset) => b.readUInt32BE(box.start + offset);
|
|
7056
|
+
if (at(4) === 0 || at(12) === 0) continue;
|
|
7057
|
+
const width = Math.round(at(0) / at(4));
|
|
7058
|
+
const height = Math.round(at(8) / at(12));
|
|
7059
|
+
if (width > ispe.width || height > ispe.height) continue;
|
|
7060
|
+
if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
|
|
7061
|
+
return { width, height };
|
|
7062
|
+
}
|
|
7063
|
+
return void 0;
|
|
7064
|
+
}
|
|
7065
|
+
function find(b, from, to, type) {
|
|
7066
|
+
for (const box of boxes(b, from, to)) if (box.type === type) return box;
|
|
7067
|
+
return void 0;
|
|
7068
|
+
}
|
|
7069
|
+
function* boxes(b, from, to) {
|
|
7070
|
+
let i = from;
|
|
7071
|
+
while (i + 8 <= to) {
|
|
7072
|
+
let size2 = b.readUInt32BE(i);
|
|
7073
|
+
const type = b.toString("latin1", i + 4, i + 8);
|
|
7074
|
+
let start = i + 8;
|
|
7075
|
+
if (size2 === 1) {
|
|
7076
|
+
if (i + 16 > to) return;
|
|
7077
|
+
const large = b.readBigUInt64BE(i + 8);
|
|
7078
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
|
|
7079
|
+
size2 = Number(large);
|
|
7080
|
+
start = i + 16;
|
|
7081
|
+
} else if (size2 === 0) {
|
|
7082
|
+
size2 = to - i;
|
|
7083
|
+
}
|
|
7084
|
+
if (size2 < start - i || i + size2 > to) return;
|
|
7085
|
+
yield { type, start, end: i + size2 };
|
|
7086
|
+
i += size2;
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
function svgFigureSize(b) {
|
|
7090
|
+
const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
|
|
7091
|
+
b.toString("utf8")
|
|
7092
|
+
);
|
|
7093
|
+
if (moving)
|
|
7094
|
+
throw new Error(
|
|
7095
|
+
`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.`
|
|
7096
|
+
);
|
|
7097
|
+
return svgSize(b);
|
|
7098
|
+
}
|
|
7099
|
+
function svgSize(bytes) {
|
|
7100
|
+
const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
|
|
7101
|
+
const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
|
|
7102
|
+
if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
|
|
7103
|
+
const width = cssPixels(attrOf(tag, "width"));
|
|
7104
|
+
const height = cssPixels(attrOf(tag, "height"));
|
|
7105
|
+
if (width !== void 0 && height !== void 0) return { width, height };
|
|
7106
|
+
const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
|
|
7107
|
+
const w = box?.[2];
|
|
7108
|
+
const h = box?.[3];
|
|
7109
|
+
if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
|
|
7110
|
+
return { width: w, height: h };
|
|
7111
|
+
throw new Error("SVG declares no absolute width and height, and no usable viewBox");
|
|
7112
|
+
}
|
|
7113
|
+
function attrOf(tag, name) {
|
|
7114
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
|
|
7115
|
+
return m?.[1] ?? m?.[2];
|
|
7116
|
+
}
|
|
7117
|
+
var UNIT = {
|
|
7118
|
+
"": 1,
|
|
7119
|
+
px: 1,
|
|
7120
|
+
pt: 96 / 72,
|
|
7121
|
+
pc: 16,
|
|
7122
|
+
in: 96,
|
|
7123
|
+
cm: 96 / 2.54,
|
|
7124
|
+
mm: 96 / 25.4,
|
|
7125
|
+
q: 96 / 101.6,
|
|
7126
|
+
em: 16,
|
|
7127
|
+
rem: 16
|
|
7128
|
+
};
|
|
7129
|
+
function cssPixels(value) {
|
|
7130
|
+
if (value === void 0) return void 0;
|
|
7131
|
+
const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
|
|
7132
|
+
if (!m?.[1]) return void 0;
|
|
7133
|
+
const scale = UNIT[(m[2] ?? "").toLowerCase()];
|
|
7134
|
+
if (scale === void 0) return void 0;
|
|
7135
|
+
const px = Number(m[1]) * scale;
|
|
7136
|
+
return px > 0 ? px : void 0;
|
|
7137
|
+
}
|
|
7138
|
+
|
|
7139
|
+
// src/source/readability.ts
|
|
7140
|
+
var CONTENT_MARKER = "data-ds-content";
|
|
7141
|
+
function readContentRegion() {
|
|
7142
|
+
const MARKER = "data-ds-content";
|
|
7143
|
+
const MIN_TEXT = 250;
|
|
7144
|
+
const MAX_ANCESTORS = 5;
|
|
7145
|
+
const SIBLING_FRACTION = 0.2;
|
|
7146
|
+
const MIN_SIBLING_SCORE = 10;
|
|
7147
|
+
const MIN_PARAGRAPH = 25;
|
|
7148
|
+
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;
|
|
7149
|
+
const MAYBE = /and|article|body|column|content|main|shadow|story/i;
|
|
7150
|
+
const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
|
|
7151
|
+
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;
|
|
7152
|
+
const CHROME_ROLES = /* @__PURE__ */ new Set([
|
|
7153
|
+
"alert",
|
|
7154
|
+
"alertdialog",
|
|
7155
|
+
"banner",
|
|
7156
|
+
"complementary",
|
|
7157
|
+
"contentinfo",
|
|
7158
|
+
"dialog",
|
|
7159
|
+
"menu",
|
|
7160
|
+
"menubar",
|
|
7161
|
+
"navigation",
|
|
7162
|
+
"search",
|
|
7163
|
+
"toolbar",
|
|
7164
|
+
"tooltip"
|
|
7165
|
+
]);
|
|
7166
|
+
const CHROME_TAGS = /* @__PURE__ */ new Set([
|
|
7167
|
+
"nav",
|
|
7168
|
+
"aside",
|
|
7169
|
+
"footer",
|
|
7170
|
+
"script",
|
|
7171
|
+
"style",
|
|
7172
|
+
"noscript",
|
|
7173
|
+
"form",
|
|
7174
|
+
"svg",
|
|
7175
|
+
"canvas",
|
|
7176
|
+
"template",
|
|
7177
|
+
"button",
|
|
7178
|
+
"select",
|
|
7179
|
+
"textarea"
|
|
7180
|
+
]);
|
|
7181
|
+
const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
|
|
7182
|
+
function textOf2(node) {
|
|
7183
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
7184
|
+
}
|
|
7185
|
+
function labelOf(el) {
|
|
7186
|
+
return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
|
|
7187
|
+
}
|
|
7188
|
+
function nameOf(el) {
|
|
7189
|
+
const id2 = el.getAttribute("id");
|
|
7190
|
+
const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
|
|
7191
|
+
return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
|
|
7192
|
+
}
|
|
7193
|
+
function pathOf(el) {
|
|
7194
|
+
const parts = [];
|
|
7195
|
+
let node = el;
|
|
7196
|
+
while (node && parts.length < 3) {
|
|
7197
|
+
parts.unshift(nameOf(node));
|
|
7198
|
+
node = node.parentElement;
|
|
7199
|
+
}
|
|
7200
|
+
return parts.join(" > ");
|
|
7201
|
+
}
|
|
7202
|
+
function round7(n3) {
|
|
7203
|
+
return Math.round(n3 * 1e3) / 1e3;
|
|
7204
|
+
}
|
|
7205
|
+
function linkDensity(el) {
|
|
7206
|
+
const total = textOf2(el).length;
|
|
7207
|
+
if (total === 0) return 0;
|
|
7208
|
+
let inLinks = 0;
|
|
7209
|
+
for (const a of el.querySelectorAll("a")) {
|
|
7210
|
+
const href = a.getAttribute("href") ?? "";
|
|
7211
|
+
inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
|
|
7212
|
+
}
|
|
7213
|
+
return inLinks / total;
|
|
7214
|
+
}
|
|
7215
|
+
function classWeight(el) {
|
|
7216
|
+
let weight = 0;
|
|
7217
|
+
for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
|
|
7218
|
+
if (label === "") continue;
|
|
7219
|
+
if (NEGATIVE.test(label)) weight -= 25;
|
|
7220
|
+
if (POSITIVE.test(label)) weight += 25;
|
|
7221
|
+
}
|
|
7222
|
+
return weight;
|
|
7223
|
+
}
|
|
7224
|
+
function tagBonus(tag) {
|
|
7225
|
+
if (tag === "div") return 5;
|
|
7226
|
+
if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
|
|
7227
|
+
if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
|
|
7228
|
+
if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
|
|
7229
|
+
return 0;
|
|
7230
|
+
}
|
|
7231
|
+
const scores = /* @__PURE__ */ new Map();
|
|
7232
|
+
function scoreOf(el) {
|
|
7233
|
+
const at = scores.get(el);
|
|
7234
|
+
if (at !== void 0) return at;
|
|
7235
|
+
const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
|
|
7236
|
+
scores.set(el, start);
|
|
7237
|
+
return start;
|
|
7238
|
+
}
|
|
7239
|
+
const removed = [];
|
|
7240
|
+
function strip(el) {
|
|
7241
|
+
const parent = el.parentElement;
|
|
7242
|
+
if (!parent) return;
|
|
7243
|
+
removed.push({ node: el, parent, next: el.nextSibling });
|
|
7244
|
+
el.remove();
|
|
7245
|
+
}
|
|
7246
|
+
function restore() {
|
|
7247
|
+
for (let i = removed.length - 1; i >= 0; i--) {
|
|
7248
|
+
const at = removed[i];
|
|
7249
|
+
if (at) at.parent.insertBefore(at.node, at.next);
|
|
7250
|
+
}
|
|
7251
|
+
removed.length = 0;
|
|
7252
|
+
}
|
|
7253
|
+
function declined(reason3, candidates2) {
|
|
7254
|
+
restore();
|
|
7255
|
+
return {
|
|
7256
|
+
marked: false,
|
|
7257
|
+
reason: reason3,
|
|
7258
|
+
candidates: candidates2,
|
|
7259
|
+
merged: 0,
|
|
7260
|
+
stripped: 0,
|
|
7261
|
+
text: 0,
|
|
7262
|
+
mediaDropped: 0
|
|
7263
|
+
};
|
|
7264
|
+
}
|
|
7265
|
+
try {
|
|
7266
|
+
if (!document.body) {
|
|
7267
|
+
return declined("the document has no <body>, so there is no region to choose", []);
|
|
7268
|
+
}
|
|
7269
|
+
const mediaBefore = document.querySelectorAll("video, iframe").length;
|
|
7270
|
+
for (const el of [...document.body.querySelectorAll("*")]) {
|
|
7271
|
+
if (!el.isConnected) continue;
|
|
7272
|
+
const tag = el.tagName.toLowerCase();
|
|
7273
|
+
if (CHROME_TAGS.has(tag)) {
|
|
7274
|
+
strip(el);
|
|
7275
|
+
continue;
|
|
7276
|
+
}
|
|
7277
|
+
if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
|
|
7278
|
+
strip(el);
|
|
7279
|
+
continue;
|
|
7280
|
+
}
|
|
7281
|
+
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
|
|
7282
|
+
strip(el);
|
|
7283
|
+
continue;
|
|
7284
|
+
}
|
|
7285
|
+
if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
|
|
7286
|
+
strip(el);
|
|
7287
|
+
continue;
|
|
7288
|
+
}
|
|
7289
|
+
const label = labelOf(el);
|
|
7290
|
+
if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
|
|
7291
|
+
strip(el);
|
|
7292
|
+
}
|
|
7293
|
+
}
|
|
7294
|
+
for (const el of document.body.querySelectorAll(SCORED)) {
|
|
7295
|
+
const text3 = textOf2(el);
|
|
7296
|
+
if (text3.length < MIN_PARAGRAPH) continue;
|
|
7297
|
+
const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
|
|
7298
|
+
let node = el.parentElement;
|
|
7299
|
+
let level = 0;
|
|
7300
|
+
while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
|
|
7301
|
+
const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
|
|
7302
|
+
scores.set(node, scoreOf(node) + base / divider);
|
|
7303
|
+
node = node.parentElement;
|
|
7304
|
+
level++;
|
|
7305
|
+
}
|
|
7306
|
+
}
|
|
7307
|
+
const ranked = [];
|
|
7308
|
+
for (const [el, content] of scores) {
|
|
7309
|
+
const density = linkDensity(el);
|
|
7310
|
+
ranked.push({
|
|
7311
|
+
el,
|
|
7312
|
+
report: {
|
|
7313
|
+
path: pathOf(el),
|
|
7314
|
+
content: round7(content),
|
|
7315
|
+
linkDensity: round7(density),
|
|
7316
|
+
// THE WHOLE POINT OF THE PASS. A rail of related headlines and an
|
|
7317
|
+
// article of the same length hold the same number of characters; only
|
|
7318
|
+
// this term separates them, and it is why the discount is a
|
|
7319
|
+
// multiplier rather than a subtraction — a candidate that is entirely
|
|
7320
|
+
// links scores zero however long it is.
|
|
7321
|
+
score: round7(content * (1 - density)),
|
|
7322
|
+
text: textOf2(el).length
|
|
7323
|
+
}
|
|
7324
|
+
});
|
|
7325
|
+
}
|
|
7326
|
+
ranked.sort((a, b) => b.report.score - a.report.score);
|
|
7327
|
+
const report = ranked.slice(0, 5).map((r) => r.report);
|
|
7328
|
+
let top = ranked[0];
|
|
7329
|
+
if (!top) {
|
|
7330
|
+
return declined(
|
|
7331
|
+
`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.`,
|
|
7332
|
+
report
|
|
7333
|
+
);
|
|
7334
|
+
}
|
|
7335
|
+
const byElement = new Map(ranked.map((r) => [r.el, r]));
|
|
7336
|
+
const floor = top.report.score / 3;
|
|
7337
|
+
let last = top.report.score;
|
|
7338
|
+
let up = top.el.parentElement;
|
|
7339
|
+
while (up && up !== document.body && up !== document.documentElement) {
|
|
7340
|
+
const here = byElement.get(up);
|
|
7341
|
+
if (here) {
|
|
7342
|
+
if (here.report.score < floor) break;
|
|
7343
|
+
if (here.report.score > last) {
|
|
7344
|
+
top = here;
|
|
7345
|
+
break;
|
|
7346
|
+
}
|
|
7347
|
+
last = here.report.score;
|
|
7348
|
+
}
|
|
7349
|
+
up = up.parentElement;
|
|
7350
|
+
}
|
|
7351
|
+
if (top.el === document.body) {
|
|
7352
|
+
const text3 = textOf2(document.body).length;
|
|
7353
|
+
if (text3 < MIN_TEXT) {
|
|
7354
|
+
return declined(
|
|
7355
|
+
`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`,
|
|
7356
|
+
report
|
|
7357
|
+
);
|
|
7358
|
+
}
|
|
7359
|
+
document.body.setAttribute(MARKER, "");
|
|
7360
|
+
return {
|
|
7361
|
+
marked: true,
|
|
7362
|
+
reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
|
|
7363
|
+
candidates: report,
|
|
7364
|
+
merged: 0,
|
|
7365
|
+
stripped: removed.length,
|
|
7366
|
+
text: text3,
|
|
7367
|
+
mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
|
|
7368
|
+
};
|
|
7369
|
+
}
|
|
7370
|
+
const parent = top.el.parentElement;
|
|
7371
|
+
if (!parent) {
|
|
7372
|
+
return declined("the winning candidate is not in the document any more", report);
|
|
7373
|
+
}
|
|
7374
|
+
const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
|
|
7375
|
+
const topClass = top.el.getAttribute("class") ?? "";
|
|
7376
|
+
const keep = [];
|
|
7377
|
+
for (const sib of [...parent.children]) {
|
|
7378
|
+
if (sib === top.el) {
|
|
7379
|
+
keep.push(sib);
|
|
7380
|
+
continue;
|
|
7381
|
+
}
|
|
7382
|
+
const twin = topClass !== "" && sib.getAttribute("class") === topClass;
|
|
7383
|
+
const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
|
|
7384
|
+
const here = byElement.get(sib);
|
|
7385
|
+
if (here && here.report.score + bonus >= threshold) {
|
|
7386
|
+
keep.push(sib);
|
|
7387
|
+
continue;
|
|
7388
|
+
}
|
|
7389
|
+
if (sib.tagName.toLowerCase() === "p") {
|
|
7390
|
+
const text3 = textOf2(sib);
|
|
7391
|
+
const density = linkDensity(sib);
|
|
7392
|
+
const long = text3.length > 80 && density < 0.25;
|
|
7393
|
+
const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
|
|
7394
|
+
if (long || sentence) keep.push(sib);
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
7397
|
+
const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
|
|
7398
|
+
if (text2 < MIN_TEXT) {
|
|
7399
|
+
return declined(
|
|
7400
|
+
`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`,
|
|
7401
|
+
report
|
|
7402
|
+
);
|
|
7403
|
+
}
|
|
7404
|
+
let region;
|
|
7405
|
+
if (keep.length === 1 && keep[0]) {
|
|
7406
|
+
region = keep[0];
|
|
7407
|
+
} else {
|
|
7408
|
+
const box = document.createElement("div");
|
|
7409
|
+
for (const el of keep) box.appendChild(el);
|
|
7410
|
+
document.body.appendChild(box);
|
|
7411
|
+
region = box;
|
|
7412
|
+
}
|
|
7413
|
+
region.setAttribute(MARKER, "");
|
|
7414
|
+
return {
|
|
7415
|
+
marked: true,
|
|
7416
|
+
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` : ""),
|
|
7417
|
+
candidates: report,
|
|
7418
|
+
merged: keep.length - 1,
|
|
7419
|
+
stripped: removed.length,
|
|
7420
|
+
text: textOf2(region).length,
|
|
7421
|
+
mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
|
|
7422
|
+
};
|
|
7423
|
+
} catch (e) {
|
|
7424
|
+
return declined(
|
|
7425
|
+
`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`,
|
|
7426
|
+
[]
|
|
7427
|
+
);
|
|
7428
|
+
}
|
|
7429
|
+
}
|
|
7430
|
+
|
|
7431
|
+
// src/source/transcode.ts
|
|
7432
|
+
import { readFile as readFile5, rm } from "node:fs/promises";
|
|
7433
|
+
import { basename } from "node:path";
|
|
7434
|
+
|
|
7435
|
+
// src/render/ffmpeg.ts
|
|
7436
|
+
import { execFile, spawn } from "node:child_process";
|
|
7437
|
+
import { promisify } from "node:util";
|
|
7438
|
+
var run = promisify(execFile);
|
|
7439
|
+
var DEFAULT_TIMEOUT_MS = 36e5;
|
|
7440
|
+
async function runTool(file, args, opts = {}) {
|
|
7441
|
+
try {
|
|
7442
|
+
return await run(file, args, {
|
|
7443
|
+
cwd: opts.cwd,
|
|
7444
|
+
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
7445
|
+
maxBuffer: 64 << 20
|
|
7446
|
+
});
|
|
7447
|
+
} catch (err) {
|
|
7448
|
+
const e = err;
|
|
7449
|
+
if (e.code === "ENOENT") {
|
|
7450
|
+
throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
|
|
7451
|
+
}
|
|
7452
|
+
const tail3 = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
|
|
7453
|
+
throw new Error(`${file} failed:
|
|
7454
|
+
${tail3}`);
|
|
7455
|
+
}
|
|
7456
|
+
}
|
|
7457
|
+
function runLive(file, args) {
|
|
7458
|
+
return new Promise((resolve6, reject) => {
|
|
7459
|
+
const child = spawn(file, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
7460
|
+
child.on("error", (err) => {
|
|
7461
|
+
reject(
|
|
7462
|
+
err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
|
|
7463
|
+
);
|
|
7464
|
+
});
|
|
7465
|
+
child.on("close", (code, signal) => {
|
|
7466
|
+
if (code === 0) resolve6();
|
|
7467
|
+
else if (signal) {
|
|
7468
|
+
reject(
|
|
7469
|
+
new Error(
|
|
7470
|
+
`${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.`
|
|
7471
|
+
)
|
|
7472
|
+
);
|
|
7473
|
+
} else reject(new Error(`${file} exited ${code}.`));
|
|
7474
|
+
});
|
|
7475
|
+
});
|
|
7476
|
+
}
|
|
7477
|
+
async function probe(path2) {
|
|
7478
|
+
const { stdout } = await runTool("ffprobe", [
|
|
7479
|
+
"-v",
|
|
7480
|
+
"error",
|
|
7481
|
+
"-show_entries",
|
|
7482
|
+
"stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
|
|
7483
|
+
"-of",
|
|
7484
|
+
"json",
|
|
7485
|
+
path2
|
|
7486
|
+
]);
|
|
7487
|
+
const json = JSON.parse(stdout);
|
|
7488
|
+
const streams = json.streams ?? [];
|
|
7489
|
+
const video = streams.find((s) => s.codec_type === "video");
|
|
7490
|
+
if (!video) throw new Error(`${path2} has no video stream.`);
|
|
7491
|
+
const [num, den] = (video.r_frame_rate ?? "30/1").split("/");
|
|
7492
|
+
const fps = Number(num) / (Number(den) || 1);
|
|
7493
|
+
const seconds = Number(json.format?.duration ?? 0);
|
|
7494
|
+
const frames2 = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
|
|
7495
|
+
return {
|
|
7496
|
+
width: video.width ?? 0,
|
|
7497
|
+
height: video.height ?? 0,
|
|
7498
|
+
fps,
|
|
7499
|
+
frames: frames2,
|
|
7500
|
+
seconds,
|
|
7501
|
+
hasAudio: streams.some((s) => s.codec_type === "audio")
|
|
7502
|
+
};
|
|
7503
|
+
}
|
|
7504
|
+
function encoderArgs(fps) {
|
|
7505
|
+
return [
|
|
7506
|
+
"-c:v",
|
|
7507
|
+
"libx264",
|
|
7508
|
+
"-preset",
|
|
7509
|
+
"veryfast",
|
|
7510
|
+
"-crf",
|
|
7511
|
+
"16",
|
|
7512
|
+
"-pix_fmt",
|
|
7513
|
+
"yuv420p",
|
|
7514
|
+
"-g",
|
|
7515
|
+
"12",
|
|
7516
|
+
"-r",
|
|
7517
|
+
String(fps),
|
|
7518
|
+
"-fps_mode",
|
|
7519
|
+
"cfr"
|
|
7520
|
+
];
|
|
7521
|
+
}
|
|
7522
|
+
function pieceFilter(motion, freeze) {
|
|
7523
|
+
const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
|
|
7524
|
+
if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
|
|
7525
|
+
return chain.join(",");
|
|
7526
|
+
}
|
|
7527
|
+
function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
|
|
7528
|
+
return [
|
|
7529
|
+
"-y",
|
|
7530
|
+
"-hide_banner",
|
|
7531
|
+
"-loglevel",
|
|
7532
|
+
"error",
|
|
7533
|
+
// Half a frame in, so a time that is exactly on a boundary cannot round to
|
|
7534
|
+
// the frame before it.
|
|
7535
|
+
"-ss",
|
|
7536
|
+
((fromFrame + 0.5) / fps).toFixed(6),
|
|
7537
|
+
"-i",
|
|
7538
|
+
source,
|
|
7539
|
+
"-an",
|
|
7540
|
+
"-vf",
|
|
7541
|
+
pieceFilter(motion, freeze),
|
|
7542
|
+
"-frames:v",
|
|
7543
|
+
String(motion + freeze),
|
|
7544
|
+
...encoderArgs(fps),
|
|
7545
|
+
"-f",
|
|
7546
|
+
"mpegts",
|
|
7547
|
+
out
|
|
7548
|
+
];
|
|
7549
|
+
}
|
|
7550
|
+
var LOUDNESS = "loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000";
|
|
7551
|
+
function audioGraph(inputs, seconds, first = 1) {
|
|
7552
|
+
const lines = inputs.map(
|
|
7553
|
+
(input, i) => `[${first + i}:a]aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo,adelay=${input.delayMs}:all=1[d${i}]`
|
|
7554
|
+
);
|
|
7555
|
+
const labels = inputs.map((_, i) => `[d${i}]`).join("");
|
|
7556
|
+
lines.push(
|
|
7557
|
+
`${labels}amix=inputs=${inputs.length}:normalize=0:dropout_transition=0:duration=longest,${LOUDNESS},apad=whole_dur=${seconds.toFixed(3)}[aout]`
|
|
7558
|
+
);
|
|
7559
|
+
return lines.join(";\n");
|
|
7560
|
+
}
|
|
7561
|
+
function respeedArgs(source, factor, chain, fps, hasAudio, out) {
|
|
7562
|
+
const video = `[0:v]setpts=PTS/${factor}[v]`;
|
|
7563
|
+
const audio = hasAudio ? `;[0:a]${chain.map((t2) => `atempo=${t2}`).join(",")}[a]` : "";
|
|
7564
|
+
return [
|
|
7565
|
+
"-y",
|
|
7566
|
+
"-hide_banner",
|
|
7567
|
+
"-loglevel",
|
|
7568
|
+
"error",
|
|
7569
|
+
"-i",
|
|
7570
|
+
source,
|
|
7571
|
+
"-filter_complex",
|
|
7572
|
+
`${video}${audio}`,
|
|
7573
|
+
"-map",
|
|
7574
|
+
"[v]",
|
|
7575
|
+
...hasAudio ? ["-map", "[a]", "-c:a", "aac", "-b:a", "160k"] : ["-an"],
|
|
7576
|
+
...encoderArgs(fps),
|
|
7577
|
+
out
|
|
7578
|
+
];
|
|
7579
|
+
}
|
|
7580
|
+
function burnStyle(width, height, font = "Arial") {
|
|
7581
|
+
return {
|
|
7582
|
+
width,
|
|
7583
|
+
height,
|
|
7584
|
+
font,
|
|
7585
|
+
// MEASURED, not guessed. `splitCue` caps a cue at 84 characters and `wrap`
|
|
7586
|
+
// breaks it near the middle, so the longer of the two lines runs to about
|
|
7587
|
+
// 46 characters in real caption prose. Bold Arial advances 0.485em per
|
|
7588
|
+
// character on that prose (measured in a browser over the demo's own
|
|
7589
|
+
// narration), so 46 characters at F px is 22.3F wide, and the usable width
|
|
7590
|
+
// here is 978px. F = 40 leaves 9% of headroom; F = 45 — which is what
|
|
7591
|
+
// "4% of the width" looked like on paper — overflows to a THIRD line, and a
|
|
7592
|
+
// three-line band covers the bottom of the slide.
|
|
7593
|
+
fontSize: Math.round(width * 0.037),
|
|
7594
|
+
// Clear of the play button, the progress bar and the handle every vertical
|
|
7595
|
+
// player draws across the bottom eighth of the frame.
|
|
7596
|
+
marginV: Math.round(height * 0.09),
|
|
7597
|
+
marginX: Math.round(width * 0.04)
|
|
7598
|
+
};
|
|
7599
|
+
}
|
|
7600
|
+
|
|
7601
|
+
// src/source/transcode.ts
|
|
7602
|
+
var CLIP_EDGE_PX = 1280;
|
|
7603
|
+
var MAX_CLIP_SECONDS = 60;
|
|
7604
|
+
var TIMEOUT_MS = 6e5;
|
|
7605
|
+
function fitBox(width, height, maxEdgePx) {
|
|
7606
|
+
const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
|
|
7607
|
+
return { width: even(width * scale), height: even(height * scale) };
|
|
7608
|
+
}
|
|
7609
|
+
function even(n3) {
|
|
7610
|
+
return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
|
|
7611
|
+
}
|
|
7612
|
+
function transcodeArgs(input, out, plan) {
|
|
7613
|
+
return [
|
|
7614
|
+
"-y",
|
|
7615
|
+
"-hide_banner",
|
|
7616
|
+
"-loglevel",
|
|
7617
|
+
"error",
|
|
7618
|
+
"-i",
|
|
7619
|
+
input,
|
|
7620
|
+
...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
|
|
7621
|
+
"-an",
|
|
7622
|
+
"-map_metadata",
|
|
7623
|
+
"-1",
|
|
7624
|
+
"-vf",
|
|
7625
|
+
`scale=${plan.width}:${plan.height}`,
|
|
7626
|
+
"-c:v",
|
|
7627
|
+
"libvpx-vp9",
|
|
7628
|
+
"-crf",
|
|
7629
|
+
"32",
|
|
7630
|
+
"-b:v",
|
|
7631
|
+
"0",
|
|
7632
|
+
"-deadline",
|
|
7633
|
+
"good",
|
|
7634
|
+
"-cpu-used",
|
|
7635
|
+
"4",
|
|
7636
|
+
"-row-mt",
|
|
7637
|
+
"1",
|
|
7638
|
+
"-pix_fmt",
|
|
7639
|
+
"yuv420p",
|
|
7640
|
+
"-fflags",
|
|
7641
|
+
"+bitexact",
|
|
7642
|
+
"-flags:v",
|
|
7643
|
+
"+bitexact",
|
|
7644
|
+
out
|
|
7645
|
+
];
|
|
7646
|
+
}
|
|
7647
|
+
var probed = /* @__PURE__ */ new Map();
|
|
7648
|
+
function installed(file) {
|
|
7649
|
+
const asked = probed.get(file);
|
|
7650
|
+
if (asked !== void 0) return asked;
|
|
7651
|
+
const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
|
|
7652
|
+
() => true,
|
|
7653
|
+
() => false
|
|
7654
|
+
);
|
|
7655
|
+
probed.set(file, answer);
|
|
7656
|
+
return answer;
|
|
7657
|
+
}
|
|
7658
|
+
async function transcode(input, out, opts = {}) {
|
|
7659
|
+
const file = opts.ffmpeg ?? "ffmpeg";
|
|
7660
|
+
const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
|
|
7661
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
7662
|
+
const name = basename(input);
|
|
7663
|
+
const source = videoSize(await readFile5(input));
|
|
7664
|
+
const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
|
|
7665
|
+
const kept = (why3) => ({
|
|
7666
|
+
path: input,
|
|
7667
|
+
width: source.width,
|
|
7668
|
+
height: source.height,
|
|
7669
|
+
seconds: source.seconds,
|
|
7670
|
+
transcoded: false,
|
|
7671
|
+
warnings: [`${name} was shipped as the page served it: ${why3}`]
|
|
7672
|
+
});
|
|
7673
|
+
if (!await installed(file)) {
|
|
7674
|
+
return kept(
|
|
7675
|
+
`${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.`
|
|
7676
|
+
);
|
|
7677
|
+
}
|
|
7678
|
+
const trim = source.seconds === void 0 || source.seconds > maxSeconds;
|
|
7679
|
+
const started = Date.now();
|
|
7680
|
+
try {
|
|
7681
|
+
await runTool(
|
|
7682
|
+
file,
|
|
7683
|
+
transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
|
|
7684
|
+
{ timeoutMs }
|
|
7685
|
+
);
|
|
7686
|
+
} catch (err) {
|
|
7687
|
+
await rm(out, { force: true });
|
|
7688
|
+
const over = Date.now() - started >= timeoutMs;
|
|
7689
|
+
return kept(
|
|
7690
|
+
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)}`
|
|
7691
|
+
);
|
|
7692
|
+
}
|
|
7693
|
+
let measured;
|
|
7694
|
+
try {
|
|
7695
|
+
measured = videoSize(await readFile5(out));
|
|
7696
|
+
} catch (err) {
|
|
7697
|
+
await rm(out, { force: true });
|
|
7698
|
+
return kept(
|
|
7699
|
+
`the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
7700
|
+
);
|
|
7701
|
+
}
|
|
7702
|
+
const warnings = [];
|
|
7703
|
+
if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
|
|
7704
|
+
warnings.push(
|
|
7705
|
+
`${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.`
|
|
7706
|
+
);
|
|
7707
|
+
}
|
|
7708
|
+
return {
|
|
7709
|
+
path: out,
|
|
7710
|
+
width: measured.width,
|
|
7711
|
+
height: measured.height,
|
|
7712
|
+
seconds: measured.seconds,
|
|
7713
|
+
transcoded: true,
|
|
7714
|
+
warnings
|
|
7715
|
+
};
|
|
7716
|
+
}
|
|
7717
|
+
|
|
7718
|
+
// src/source/harvest.ts
|
|
7719
|
+
var HTML_MAX_BYTES = 8 * 1024 * 1024;
|
|
7720
|
+
var ASSET_MAX_BYTES = 32 * 1024 * 1024;
|
|
7721
|
+
var TIMEOUT_MS2 = 2e4;
|
|
7722
|
+
var MAX_ASSETS = 40;
|
|
7723
|
+
var MAX_CLIPS = 4;
|
|
7724
|
+
var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
|
|
7725
|
+
var MAX_WALL_MS = 18e4;
|
|
7726
|
+
var MIN_FIGURE_PX = 64;
|
|
7727
|
+
var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
|
|
7728
|
+
async function harvest(url, dir, opts = {}) {
|
|
7729
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
|
|
7730
|
+
const warnings = [];
|
|
7731
|
+
const assets = resolve(dir);
|
|
7732
|
+
await mkdir4(assets, { recursive: true });
|
|
7733
|
+
const page = await fetchGuarded(url, {
|
|
7734
|
+
maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
|
|
7735
|
+
timeoutMs,
|
|
7736
|
+
accept: HTML_TYPES,
|
|
7737
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
7738
|
+
});
|
|
7739
|
+
const html = decodeHtml(page.bytes, page.contentType, url, warnings);
|
|
7740
|
+
const { seen, pick } = await readInBrowser(html, timeoutMs);
|
|
7741
|
+
if (!pick.marked) {
|
|
7742
|
+
warnings.push(
|
|
7743
|
+
`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.`
|
|
7744
|
+
);
|
|
7745
|
+
} else if (pick.mediaDropped > 0) {
|
|
7746
|
+
warnings.push(
|
|
7747
|
+
`${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.`
|
|
7748
|
+
);
|
|
7749
|
+
}
|
|
7750
|
+
const base = absolute(seen.base, page.url) ?? page.url;
|
|
7751
|
+
const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
|
|
7752
|
+
return {
|
|
7753
|
+
markdown: toMarkdown(titled(seen.title, local.blocks)),
|
|
7754
|
+
assets: local.assets,
|
|
7755
|
+
clips: local.clips,
|
|
7756
|
+
warnings,
|
|
7757
|
+
title: seen.title
|
|
7758
|
+
};
|
|
7759
|
+
}
|
|
7760
|
+
function titled(title2, blocks) {
|
|
7761
|
+
const first = blocks[0];
|
|
7762
|
+
const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
|
|
7763
|
+
return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
|
|
7764
|
+
}
|
|
7765
|
+
function decodeHtml(bytes, contentType, url, warnings) {
|
|
7766
|
+
const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
|
|
7767
|
+
const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
|
|
7768
|
+
const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
|
|
7769
|
+
const label = declared ?? meta;
|
|
7770
|
+
if (label === void 0) return bytes.toString("utf8");
|
|
7771
|
+
try {
|
|
7772
|
+
return new TextDecoder(label).decode(bytes);
|
|
7773
|
+
} catch {
|
|
7774
|
+
warnings.push(
|
|
7775
|
+
`${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.`
|
|
7776
|
+
);
|
|
7777
|
+
return bytes.toString("utf8");
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
async function readInBrowser(html, timeoutMs) {
|
|
7781
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
7782
|
+
const browser = await puppeteer.launch({
|
|
7783
|
+
executablePath: await chromePath("read the page with"),
|
|
7784
|
+
headless: true,
|
|
7785
|
+
// Chrome's own background traffic — variations, safe browsing, first-run
|
|
7786
|
+
// pings — never goes through page interception, so it is switched off here
|
|
7787
|
+
// rather than assumed absent. It is not an SSRF path, but "the browser makes
|
|
7788
|
+
// no requests" should be true of the whole process, not just of the tab.
|
|
7789
|
+
args: [
|
|
7790
|
+
"--disable-background-networking",
|
|
7791
|
+
"--disable-extensions",
|
|
7792
|
+
"--no-default-browser-check",
|
|
7793
|
+
"--no-first-run"
|
|
7794
|
+
]
|
|
7795
|
+
});
|
|
7796
|
+
try {
|
|
7797
|
+
const page = await browser.newPage();
|
|
7798
|
+
await page.setRequestInterception(true);
|
|
7799
|
+
page.on("request", (request) => {
|
|
7800
|
+
request.abort().catch(() => {
|
|
7801
|
+
});
|
|
7802
|
+
});
|
|
7803
|
+
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
7804
|
+
const pick = await page.evaluate(readContentRegion);
|
|
7805
|
+
return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
|
|
7806
|
+
} finally {
|
|
7807
|
+
await browser.close().catch(() => {
|
|
7808
|
+
});
|
|
7809
|
+
}
|
|
7810
|
+
}
|
|
7811
|
+
function readDom(marker) {
|
|
7812
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
7813
|
+
"nav",
|
|
7814
|
+
"aside",
|
|
7815
|
+
"footer",
|
|
7816
|
+
"script",
|
|
7817
|
+
"style",
|
|
7818
|
+
"noscript",
|
|
7819
|
+
"form",
|
|
7820
|
+
"svg",
|
|
7821
|
+
"canvas",
|
|
7822
|
+
"template",
|
|
7823
|
+
"button",
|
|
7824
|
+
"select",
|
|
7825
|
+
"textarea"
|
|
7826
|
+
]);
|
|
7827
|
+
const BLOCKY = /* @__PURE__ */ new Set([
|
|
7828
|
+
"p",
|
|
7829
|
+
"div",
|
|
7830
|
+
"section",
|
|
7831
|
+
"article",
|
|
7832
|
+
"main",
|
|
7833
|
+
"ul",
|
|
7834
|
+
"ol",
|
|
7835
|
+
"table",
|
|
7836
|
+
"figure",
|
|
7837
|
+
"blockquote",
|
|
7838
|
+
"pre",
|
|
7839
|
+
"h1",
|
|
7840
|
+
"h2",
|
|
7841
|
+
"h3",
|
|
7842
|
+
"h4",
|
|
7843
|
+
"h5",
|
|
7844
|
+
"h6"
|
|
7845
|
+
]);
|
|
7846
|
+
const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
|
|
7847
|
+
function words2(node) {
|
|
7848
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
7849
|
+
}
|
|
7850
|
+
function skipped(el) {
|
|
7851
|
+
return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
7852
|
+
}
|
|
7853
|
+
function pickSrc(el) {
|
|
7854
|
+
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 }));
|
|
7855
|
+
let widest2 = candidates2[0];
|
|
7856
|
+
for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
|
|
7857
|
+
return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
|
|
7858
|
+
}
|
|
7859
|
+
function imageOf(el, caption) {
|
|
7860
|
+
const src = pickSrc(el);
|
|
7861
|
+
if (!src) return void 0;
|
|
7862
|
+
return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
|
|
7863
|
+
}
|
|
7864
|
+
function videoOf(el, caption) {
|
|
7865
|
+
const source = el.querySelector("source");
|
|
7866
|
+
return {
|
|
7867
|
+
kind: "video",
|
|
7868
|
+
src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
|
|
7869
|
+
poster: el.getAttribute("poster") ?? "",
|
|
7870
|
+
href: "",
|
|
7871
|
+
caption
|
|
7872
|
+
};
|
|
7873
|
+
}
|
|
7874
|
+
function figureOf(el, out) {
|
|
7875
|
+
const caption = words2(el.querySelector("figcaption"));
|
|
7876
|
+
const video = el.querySelector("video");
|
|
7877
|
+
if (video) {
|
|
7878
|
+
out.push(videoOf(video, caption));
|
|
7879
|
+
return;
|
|
7880
|
+
}
|
|
7881
|
+
const image2 = el.querySelector("img");
|
|
7882
|
+
const block2 = image2 ? imageOf(image2, caption) : void 0;
|
|
7883
|
+
if (block2) {
|
|
7884
|
+
out.push(block2);
|
|
7885
|
+
return;
|
|
7886
|
+
}
|
|
7887
|
+
const text2 = words2(el);
|
|
7888
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
7889
|
+
}
|
|
7890
|
+
function paragraphOf(el, out) {
|
|
7891
|
+
const solid = [...el.childNodes].filter(
|
|
7892
|
+
(n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
|
|
7893
|
+
);
|
|
7894
|
+
const only = solid.length === 1 ? solid[0] : void 0;
|
|
7895
|
+
if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
|
|
7896
|
+
const href = only.getAttribute("href") ?? "";
|
|
7897
|
+
out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
|
|
7898
|
+
return;
|
|
7899
|
+
}
|
|
7900
|
+
let buffer = "";
|
|
7901
|
+
const flush = () => {
|
|
7902
|
+
const text2 = buffer.replace(/\s+/g, " ").trim();
|
|
7903
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
7904
|
+
buffer = "";
|
|
7905
|
+
};
|
|
7906
|
+
const scan = (node) => {
|
|
7907
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
7908
|
+
buffer += node.textContent ?? "";
|
|
7909
|
+
return;
|
|
7910
|
+
}
|
|
7911
|
+
if (!(node instanceof Element)) return;
|
|
7912
|
+
if (skipped(node)) return;
|
|
7913
|
+
const tag = node.tagName.toLowerCase();
|
|
7914
|
+
if (tag === "br") {
|
|
7915
|
+
buffer += " ";
|
|
7916
|
+
return;
|
|
7917
|
+
}
|
|
7918
|
+
if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
|
|
7919
|
+
flush();
|
|
7920
|
+
block(node, out);
|
|
7921
|
+
return;
|
|
7922
|
+
}
|
|
7923
|
+
for (const kid of node.childNodes) scan(kid);
|
|
7924
|
+
};
|
|
7925
|
+
for (const kid of el.childNodes) scan(kid);
|
|
7926
|
+
flush();
|
|
7927
|
+
}
|
|
7928
|
+
function listOf(el) {
|
|
7929
|
+
return {
|
|
7930
|
+
kind: "list",
|
|
7931
|
+
ordered: el.tagName === "OL",
|
|
7932
|
+
// A nested list flattens into its parent item, exactly as `blockText` in
|
|
7933
|
+
// src/source/markdown.ts already flattens one on the way back out.
|
|
7934
|
+
items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
|
|
7935
|
+
};
|
|
7936
|
+
}
|
|
7937
|
+
function tableOf(el) {
|
|
7938
|
+
const rows = [...el.querySelectorAll("tr")].map(
|
|
7939
|
+
(tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
|
|
7940
|
+
);
|
|
7941
|
+
const [head, ...body] = rows;
|
|
7942
|
+
return head ? { kind: "table", columns: head, rows: body } : void 0;
|
|
7943
|
+
}
|
|
7944
|
+
function block(el, out) {
|
|
7945
|
+
if (skipped(el)) return;
|
|
7946
|
+
const tag = el.tagName.toLowerCase();
|
|
7947
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
7948
|
+
const text2 = words2(el);
|
|
7949
|
+
if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
|
|
7950
|
+
return;
|
|
7951
|
+
}
|
|
7952
|
+
if (tag === "p" || tag === "blockquote") {
|
|
7953
|
+
paragraphOf(el, out);
|
|
7954
|
+
return;
|
|
7955
|
+
}
|
|
7956
|
+
if (tag === "ul" || tag === "ol") {
|
|
7957
|
+
const list = listOf(el);
|
|
7958
|
+
if (list.kind === "list" && list.items.length > 0) out.push(list);
|
|
7959
|
+
return;
|
|
7960
|
+
}
|
|
7961
|
+
if (tag === "table") {
|
|
7962
|
+
const table = tableOf(el);
|
|
7963
|
+
if (table) out.push(table);
|
|
7964
|
+
return;
|
|
7965
|
+
}
|
|
7966
|
+
if (tag === "figure") {
|
|
7967
|
+
figureOf(el, out);
|
|
7968
|
+
return;
|
|
7969
|
+
}
|
|
7970
|
+
if (tag === "img") {
|
|
7971
|
+
const image2 = imageOf(el, "");
|
|
7972
|
+
if (image2) out.push(image2);
|
|
7973
|
+
return;
|
|
7974
|
+
}
|
|
7975
|
+
if (tag === "video") {
|
|
7976
|
+
out.push(videoOf(el, ""));
|
|
7977
|
+
return;
|
|
7978
|
+
}
|
|
7979
|
+
if (tag === "iframe") {
|
|
7980
|
+
const href = el.getAttribute("src") ?? "";
|
|
7981
|
+
if (PLAYER.test(href)) {
|
|
7982
|
+
out.push({
|
|
7983
|
+
kind: "video",
|
|
7984
|
+
src: "",
|
|
7985
|
+
poster: "",
|
|
7986
|
+
href,
|
|
7987
|
+
caption: words2(el.getAttributeNode("title"))
|
|
7988
|
+
});
|
|
7989
|
+
}
|
|
7990
|
+
return;
|
|
7991
|
+
}
|
|
7992
|
+
if (tag === "pre") {
|
|
7993
|
+
const text2 = (el.textContent ?? "").replace(/\s+$/, "");
|
|
7994
|
+
if (text2) out.push({ kind: "code", text: text2 });
|
|
7995
|
+
return;
|
|
7996
|
+
}
|
|
7997
|
+
const direct = [...el.childNodes].some(
|
|
7998
|
+
(n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
|
|
7999
|
+
);
|
|
8000
|
+
if (direct) {
|
|
8001
|
+
paragraphOf(el, out);
|
|
8002
|
+
return;
|
|
8003
|
+
}
|
|
8004
|
+
walk(el, out);
|
|
8005
|
+
}
|
|
8006
|
+
function walk(el, out) {
|
|
8007
|
+
for (const kid of el.children) block(kid, out);
|
|
8008
|
+
}
|
|
8009
|
+
function score(el) {
|
|
8010
|
+
let text2 = 0;
|
|
8011
|
+
for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
|
|
8012
|
+
text2 += (p.textContent ?? "").trim().length;
|
|
8013
|
+
}
|
|
8014
|
+
let links = 0;
|
|
8015
|
+
for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
|
|
8016
|
+
return text2 - links;
|
|
8017
|
+
}
|
|
8018
|
+
function pickRoot() {
|
|
8019
|
+
const marked = document.querySelector(`[${marker}]`);
|
|
8020
|
+
if (marked) return marked;
|
|
8021
|
+
const main = document.querySelector("main");
|
|
8022
|
+
if (main && score(main) > 0) return main;
|
|
8023
|
+
const article = document.querySelector("article");
|
|
8024
|
+
if (article && score(article) > 0) return article;
|
|
8025
|
+
let best = document.body;
|
|
8026
|
+
let top = score(document.body);
|
|
8027
|
+
for (const el of document.body.querySelectorAll("div, section, td")) {
|
|
8028
|
+
const here = score(el);
|
|
8029
|
+
if (here >= top) {
|
|
8030
|
+
top = here;
|
|
8031
|
+
best = el;
|
|
8032
|
+
}
|
|
8033
|
+
}
|
|
8034
|
+
return best;
|
|
8035
|
+
}
|
|
8036
|
+
const root = pickRoot();
|
|
8037
|
+
const blocks = [];
|
|
8038
|
+
walk(root, blocks);
|
|
8039
|
+
const heading = words2(root.querySelector("h1")) || words2(document.querySelector("h1"));
|
|
8040
|
+
const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
|
|
8041
|
+
return {
|
|
8042
|
+
title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
|
|
8043
|
+
base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
|
|
8044
|
+
ogImage: og?.getAttribute("content") ?? "",
|
|
8045
|
+
blocks
|
|
8046
|
+
};
|
|
8047
|
+
}
|
|
8048
|
+
async function localise(seen, base, dir, opts, timeoutMs, warnings) {
|
|
8049
|
+
const maxAssets = opts.maxAssets ?? MAX_ASSETS;
|
|
8050
|
+
const maxClips = opts.maxClips ?? MAX_CLIPS;
|
|
8051
|
+
const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
8052
|
+
const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
|
|
8053
|
+
const deadline = Date.now() + wallMs;
|
|
8054
|
+
const out = [];
|
|
8055
|
+
const assets = [];
|
|
8056
|
+
const clips = [];
|
|
8057
|
+
let spent = 0;
|
|
8058
|
+
const done = /* @__PURE__ */ new Map();
|
|
8059
|
+
const ref = (got) => opts.refs === "relative" ? basename2(got.path) : got.path;
|
|
8060
|
+
const overdrawn = () => {
|
|
8061
|
+
if (Date.now() >= deadline) {
|
|
8062
|
+
return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
|
|
8063
|
+
}
|
|
8064
|
+
if (spent >= maxBytes) {
|
|
8065
|
+
return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
|
|
8066
|
+
}
|
|
8067
|
+
return null;
|
|
8068
|
+
};
|
|
8069
|
+
const bytesOf = async (url) => {
|
|
8070
|
+
const got = await fetchGuarded(url, {
|
|
8071
|
+
maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
|
|
8072
|
+
timeoutMs,
|
|
8073
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
8074
|
+
});
|
|
8075
|
+
spent += got.bytes.length;
|
|
8076
|
+
return got.bytes;
|
|
8077
|
+
};
|
|
8078
|
+
const grab = async (raw2, what) => {
|
|
8079
|
+
const url = absolute(raw2, base);
|
|
8080
|
+
if (url === null) {
|
|
8081
|
+
warnings.push(
|
|
8082
|
+
`${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.`
|
|
8083
|
+
);
|
|
8084
|
+
return null;
|
|
8085
|
+
}
|
|
8086
|
+
const already = done.get(url);
|
|
8087
|
+
if (already !== void 0) return already;
|
|
8088
|
+
if (assets.length >= maxAssets) {
|
|
8089
|
+
done.set(url, null);
|
|
8090
|
+
warnings.push(
|
|
8091
|
+
`${what} was left out: ${url} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
|
|
8092
|
+
);
|
|
8093
|
+
return null;
|
|
8094
|
+
}
|
|
8095
|
+
const capped = overdrawn();
|
|
8096
|
+
if (capped !== null) {
|
|
8097
|
+
done.set(url, null);
|
|
8098
|
+
warnings.push(`${what} was left out: ${url} \u2014 ${capped} if the page is worth the wait.`);
|
|
8099
|
+
return null;
|
|
8100
|
+
}
|
|
8101
|
+
let got = null;
|
|
8102
|
+
try {
|
|
8103
|
+
const bytes = await bytesOf(url);
|
|
8104
|
+
const size2 = imageSize(bytes);
|
|
8105
|
+
if (size2.width < MIN_FIGURE_PX || size2.height < MIN_FIGURE_PX) {
|
|
8106
|
+
throw new Error(
|
|
8107
|
+
`it is ${size2.width}x${size2.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
|
|
8108
|
+
);
|
|
8109
|
+
}
|
|
8110
|
+
const path2 = join4(dir, assetName(url, extFor2(sniffFormat(bytes))));
|
|
8111
|
+
await writeFile4(path2, bytes);
|
|
8112
|
+
assets.push(path2);
|
|
8113
|
+
got = { path: path2, width: size2.width, height: size2.height };
|
|
8114
|
+
} catch (err) {
|
|
8115
|
+
got = null;
|
|
8116
|
+
warnings.push(`${what} was left out: ${url} \u2014 ${why(err)}`);
|
|
8117
|
+
}
|
|
8118
|
+
done.set(url, got);
|
|
8119
|
+
return got;
|
|
8120
|
+
};
|
|
8121
|
+
const shrink = async (url, path2, measured, what) => {
|
|
8122
|
+
if (opts.transcode === false) return { path: path2, ...measured };
|
|
8123
|
+
const out2 = join4(dir, assetName(url, ".vp9.webm"));
|
|
8124
|
+
let small;
|
|
8125
|
+
try {
|
|
8126
|
+
small = await transcode(path2, out2, {
|
|
8127
|
+
...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
|
|
8128
|
+
});
|
|
8129
|
+
} catch (err) {
|
|
8130
|
+
warnings.push(`${what} was shipped as the page served it: ${why(err)}`);
|
|
8131
|
+
return { path: path2, ...measured };
|
|
8132
|
+
}
|
|
8133
|
+
for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
|
|
8134
|
+
if (small.transcoded) {
|
|
8135
|
+
const [before, after] = await Promise.all([sizeOf(path2), sizeOf(small.path)]);
|
|
8136
|
+
if (before > 0 && after > before) {
|
|
8137
|
+
warnings.push(
|
|
8138
|
+
`${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.`
|
|
8139
|
+
);
|
|
8140
|
+
}
|
|
8141
|
+
await rm2(path2, { force: true });
|
|
8142
|
+
}
|
|
8143
|
+
return {
|
|
8144
|
+
path: small.path,
|
|
8145
|
+
width: small.width,
|
|
8146
|
+
height: small.height,
|
|
8147
|
+
...small.seconds === void 0 ? {} : { seconds: small.seconds }
|
|
8148
|
+
};
|
|
8149
|
+
};
|
|
8150
|
+
const grabVideo = async (url, what) => {
|
|
8151
|
+
if (clips.length >= maxClips) {
|
|
8152
|
+
warnings.push(
|
|
8153
|
+
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.`
|
|
8154
|
+
);
|
|
8155
|
+
return null;
|
|
8156
|
+
}
|
|
8157
|
+
const capped = overdrawn();
|
|
8158
|
+
if (capped !== null) {
|
|
8159
|
+
warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
|
|
8160
|
+
return null;
|
|
8161
|
+
}
|
|
8162
|
+
try {
|
|
8163
|
+
const bytes = await bytesOf(url);
|
|
8164
|
+
const measured = videoSize(bytes);
|
|
8165
|
+
const path2 = join4(dir, assetName(url, `.${measured.container}`));
|
|
8166
|
+
await writeFile4(path2, bytes);
|
|
8167
|
+
return await shrink(url, path2, measured, what);
|
|
8168
|
+
} catch (err) {
|
|
8169
|
+
warnings.push(`${what} was not downloaded: ${url} \u2014 ${why(err)}`);
|
|
8170
|
+
return null;
|
|
8171
|
+
}
|
|
8172
|
+
};
|
|
8173
|
+
let ogSpent = false;
|
|
8174
|
+
const stillFor = async (poster, named, needed) => {
|
|
8175
|
+
if (poster) return grab(poster, `the poster of ${named}`);
|
|
8176
|
+
if (needed && seen.ogImage && !ogSpent) {
|
|
8177
|
+
ogSpent = true;
|
|
8178
|
+
return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
|
|
8179
|
+
}
|
|
8180
|
+
return null;
|
|
8181
|
+
};
|
|
8182
|
+
for (const item of seen.blocks) {
|
|
8183
|
+
if (item.kind === "image") {
|
|
8184
|
+
const named = item.caption || item.alt;
|
|
8185
|
+
const got = await grab(item.src, named ? `the image "${named}"` : "an image");
|
|
8186
|
+
if (got) out.push({ ...item, src: ref(got) });
|
|
8187
|
+
continue;
|
|
8188
|
+
}
|
|
8189
|
+
if (item.kind === "video") {
|
|
8190
|
+
const named = item.caption ? `the video "${item.caption}"` : "a video";
|
|
8191
|
+
const file = absolute(item.src, base);
|
|
8192
|
+
const page = absolute(item.href, base);
|
|
8193
|
+
const subject = file ?? page;
|
|
8194
|
+
const watch = page ?? file;
|
|
8195
|
+
const policy = subject === null ? null : policyFor(subject, "bake");
|
|
8196
|
+
const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
|
|
8197
|
+
const still = await stillFor(item.poster, named, held === null);
|
|
8198
|
+
if (held !== null) {
|
|
8199
|
+
clips.push({
|
|
8200
|
+
file: held.path,
|
|
8201
|
+
poster: still?.path ?? "",
|
|
8202
|
+
href: "",
|
|
8203
|
+
width: held.width,
|
|
8204
|
+
height: held.height,
|
|
8205
|
+
...held.seconds === void 0 ? {} : { seconds: held.seconds },
|
|
8206
|
+
caption: item.caption
|
|
8207
|
+
});
|
|
8208
|
+
out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
|
|
8209
|
+
continue;
|
|
8210
|
+
}
|
|
8211
|
+
if (still !== null && watch !== null) {
|
|
8212
|
+
clips.push({
|
|
8213
|
+
file: "",
|
|
8214
|
+
poster: still.path,
|
|
8215
|
+
href: watch,
|
|
8216
|
+
width: still.width,
|
|
8217
|
+
height: still.height,
|
|
8218
|
+
caption: item.caption
|
|
8219
|
+
});
|
|
8220
|
+
warnings.push(
|
|
8221
|
+
`${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
|
|
8222
|
+
);
|
|
8223
|
+
out.push({ ...item, src: "", poster: ref(still), href: watch });
|
|
8224
|
+
continue;
|
|
8225
|
+
}
|
|
8226
|
+
if (still !== null) {
|
|
8227
|
+
warnings.push(
|
|
8228
|
+
`${named} is a still only: the page names no source for it, so there is nothing to play.`
|
|
8229
|
+
);
|
|
8230
|
+
out.push({ ...item, src: "", poster: ref(still), href: "" });
|
|
8231
|
+
continue;
|
|
8232
|
+
}
|
|
8233
|
+
if (watch === null) {
|
|
8234
|
+
warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
|
|
8235
|
+
continue;
|
|
8236
|
+
}
|
|
8237
|
+
warnings.push(
|
|
8238
|
+
`${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.`
|
|
8239
|
+
);
|
|
8240
|
+
out.push({ ...item, src: "", poster: "", href: watch });
|
|
8241
|
+
continue;
|
|
8242
|
+
}
|
|
8243
|
+
out.push(item);
|
|
8244
|
+
}
|
|
8245
|
+
return { blocks: out, assets, clips };
|
|
8246
|
+
}
|
|
8247
|
+
function unheld(policy) {
|
|
8248
|
+
if (policy === "embed") return "it is a player page, which is never downloaded";
|
|
8249
|
+
if (policy === "link") {
|
|
8250
|
+
return "its URL does not end in a video extension, so what came back could as easily be a page";
|
|
8251
|
+
}
|
|
8252
|
+
return "the file could not be held";
|
|
8253
|
+
}
|
|
8254
|
+
async function sizeOf(path2) {
|
|
8255
|
+
return await stat(path2).then(
|
|
8256
|
+
(s) => s.size,
|
|
8257
|
+
() => 0
|
|
8258
|
+
);
|
|
8259
|
+
}
|
|
8260
|
+
function mb(bytes) {
|
|
8261
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
8262
|
+
}
|
|
8263
|
+
function absolute(raw2, base) {
|
|
8264
|
+
const trimmed = raw2.trim();
|
|
8265
|
+
if (!trimmed) return null;
|
|
8266
|
+
try {
|
|
8267
|
+
const url = new URL(trimmed, base);
|
|
8268
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
8269
|
+
} catch {
|
|
8270
|
+
return null;
|
|
8271
|
+
}
|
|
8272
|
+
}
|
|
8273
|
+
function assetName(url, ext) {
|
|
8274
|
+
const last = new URL(url).pathname.split("/").pop() ?? "";
|
|
8275
|
+
const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
|
|
8276
|
+
const hash = createHash5("sha256").update(url).digest("hex").slice(0, 8);
|
|
8277
|
+
return `${stem}-${hash}${ext}`;
|
|
8278
|
+
}
|
|
8279
|
+
function extFor2(format) {
|
|
8280
|
+
if (format === void 0) return ".img";
|
|
8281
|
+
return format === "jpeg" ? ".jpg" : `.${format}`;
|
|
8282
|
+
}
|
|
8283
|
+
function why(err) {
|
|
8284
|
+
return err instanceof Error ? err.message : String(err);
|
|
8285
|
+
}
|
|
8286
|
+
function sniffVideo(b) {
|
|
8287
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
|
|
8288
|
+
if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
|
|
8289
|
+
return void 0;
|
|
8290
|
+
}
|
|
8291
|
+
function videoSize(b) {
|
|
8292
|
+
const container = sniffVideo(b);
|
|
8293
|
+
if (container === void 0) {
|
|
8294
|
+
throw new Error(
|
|
8295
|
+
"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"
|
|
8296
|
+
);
|
|
8297
|
+
}
|
|
8298
|
+
const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
|
|
8299
|
+
if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
|
|
8300
|
+
throw new Error(
|
|
8301
|
+
`it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
|
|
8302
|
+
);
|
|
8303
|
+
}
|
|
8304
|
+
return { ...measured, container };
|
|
8305
|
+
}
|
|
8306
|
+
function boxes2(b, from, to) {
|
|
8307
|
+
const out = [];
|
|
8308
|
+
let at = from;
|
|
8309
|
+
while (at + 8 <= to) {
|
|
8310
|
+
let size2 = b.readUInt32BE(at);
|
|
8311
|
+
const type = b.toString("latin1", at + 4, at + 8);
|
|
8312
|
+
let body = at + 8;
|
|
8313
|
+
if (size2 === 1) {
|
|
8314
|
+
if (body + 8 > to) return out;
|
|
8315
|
+
size2 = Number(b.readBigUInt64BE(body));
|
|
8316
|
+
body += 8;
|
|
8317
|
+
} else if (size2 === 0) {
|
|
8318
|
+
size2 = to - at;
|
|
8319
|
+
}
|
|
8320
|
+
const end = at + size2;
|
|
8321
|
+
if (size2 < body - at || end > to) return out;
|
|
8322
|
+
out.push({ type, body, end });
|
|
8323
|
+
at = end;
|
|
8324
|
+
}
|
|
8325
|
+
return out;
|
|
8326
|
+
}
|
|
8327
|
+
function mp4Size(b) {
|
|
8328
|
+
const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
|
|
8329
|
+
if (moov === void 0) {
|
|
8330
|
+
throw new Error(
|
|
8331
|
+
"it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
|
|
8332
|
+
);
|
|
8333
|
+
}
|
|
8334
|
+
let seconds;
|
|
8335
|
+
let size2;
|
|
8336
|
+
for (const box of boxes2(b, moov.body, moov.end)) {
|
|
8337
|
+
if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
|
|
8338
|
+
if (box.type !== "trak") continue;
|
|
8339
|
+
for (const inner of boxes2(b, box.body, box.end)) {
|
|
8340
|
+
if (inner.type === "tkhd" && size2 === void 0) size2 = tkhdSize(b, inner.body);
|
|
8341
|
+
}
|
|
8342
|
+
}
|
|
8343
|
+
if (size2 === void 0) {
|
|
8344
|
+
throw new Error(
|
|
8345
|
+
"no track in it declares a width and a height, so there is no picture to place"
|
|
8346
|
+
);
|
|
8347
|
+
}
|
|
8348
|
+
return { ...size2, ...seconds === void 0 ? {} : { seconds } };
|
|
8349
|
+
}
|
|
8350
|
+
function tkhdSize(b, body) {
|
|
8351
|
+
const matrix = body + (b[body] === 1 ? 52 : 40);
|
|
8352
|
+
const at = matrix + 36;
|
|
8353
|
+
if (at + 8 > b.length) return void 0;
|
|
8354
|
+
const width = Math.round(b.readUInt32BE(at) / 65536);
|
|
8355
|
+
const height = Math.round(b.readUInt32BE(at + 4) / 65536);
|
|
8356
|
+
if (width <= 0 || height <= 0) return void 0;
|
|
8357
|
+
const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
|
|
8358
|
+
return quarterTurn ? { width: height, height: width } : { width, height };
|
|
8359
|
+
}
|
|
8360
|
+
function mvhdSeconds(b, body) {
|
|
8361
|
+
const long = b[body] === 1;
|
|
8362
|
+
const at = body + (long ? 20 : 12);
|
|
8363
|
+
if (at + (long ? 12 : 8) > b.length) return void 0;
|
|
8364
|
+
const timescale = b.readUInt32BE(at);
|
|
8365
|
+
const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
|
|
8366
|
+
if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
|
|
8367
|
+
return sane(ticks / timescale);
|
|
8368
|
+
}
|
|
8369
|
+
var EBML_SEGMENT = 408125543;
|
|
8370
|
+
var EBML_INFO = 357149030;
|
|
8371
|
+
var EBML_TRACKS = 374648427;
|
|
8372
|
+
var EBML_TRACK_ENTRY = 174;
|
|
8373
|
+
var EBML_VIDEO = 224;
|
|
8374
|
+
var EBML_PIXEL_WIDTH = 176;
|
|
8375
|
+
var EBML_PIXEL_HEIGHT = 186;
|
|
8376
|
+
var EBML_DISPLAY_WIDTH = 21680;
|
|
8377
|
+
var EBML_DISPLAY_HEIGHT = 21690;
|
|
8378
|
+
var EBML_TIMECODE_SCALE = 2807729;
|
|
8379
|
+
var EBML_DURATION = 17545;
|
|
8380
|
+
function webmSize(b) {
|
|
8381
|
+
let pixel = { width: 0, height: 0 };
|
|
8382
|
+
let display = { width: 0, height: 0 };
|
|
8383
|
+
let scale = 1e6;
|
|
8384
|
+
let ticks;
|
|
8385
|
+
const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
|
|
8386
|
+
const scan = (from, to, depth) => {
|
|
8387
|
+
let at = from;
|
|
8388
|
+
while (at < to) {
|
|
8389
|
+
const el = ebml(b, at, to);
|
|
8390
|
+
if (el === void 0 || el.end <= at) return;
|
|
8391
|
+
if (masters.has(el.id)) {
|
|
8392
|
+
if (depth < 6) scan(el.body, el.end, depth + 1);
|
|
8393
|
+
} else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
|
|
8394
|
+
pixel = { ...pixel, width: uint(b, el) };
|
|
8395
|
+
} else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
|
|
8396
|
+
pixel = { ...pixel, height: uint(b, el) };
|
|
8397
|
+
} else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
|
|
8398
|
+
display = { ...display, width: uint(b, el) };
|
|
8399
|
+
} else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
|
|
8400
|
+
display = { ...display, height: uint(b, el) };
|
|
8401
|
+
} else if (el.id === EBML_TIMECODE_SCALE) {
|
|
8402
|
+
scale = uint(b, el) || scale;
|
|
8403
|
+
} else if (el.id === EBML_DURATION) {
|
|
8404
|
+
ticks = float(b, el);
|
|
8405
|
+
}
|
|
8406
|
+
at = el.end;
|
|
8407
|
+
}
|
|
8408
|
+
};
|
|
8409
|
+
scan(0, b.length, 0);
|
|
8410
|
+
const width = display.width || pixel.width;
|
|
8411
|
+
const height = display.height || pixel.height;
|
|
8412
|
+
if (width <= 0 || height <= 0) {
|
|
8413
|
+
throw new Error(
|
|
8414
|
+
"no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
|
|
8415
|
+
);
|
|
8416
|
+
}
|
|
8417
|
+
const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
|
|
8418
|
+
return { width, height, ...seconds === void 0 ? {} : { seconds } };
|
|
8419
|
+
}
|
|
8420
|
+
function ebml(b, at, to) {
|
|
8421
|
+
const idLen = vintLen(b[at]);
|
|
8422
|
+
if (idLen === 0 || at + idLen > to) return void 0;
|
|
8423
|
+
let id2 = 0;
|
|
8424
|
+
for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
|
|
8425
|
+
let p = at + idLen;
|
|
8426
|
+
const sizeLen = vintLen(b[p]);
|
|
8427
|
+
if (sizeLen === 0 || p + sizeLen > to) return void 0;
|
|
8428
|
+
const first = b[p] ?? 0;
|
|
8429
|
+
const mask = 255 >> sizeLen;
|
|
8430
|
+
let size2 = first & mask;
|
|
8431
|
+
let unknown = size2 === mask;
|
|
8432
|
+
for (let i = 1; i < sizeLen; i += 1) {
|
|
8433
|
+
const byte = b[p + i] ?? 0;
|
|
8434
|
+
size2 = size2 * 256 + byte;
|
|
8435
|
+
unknown = unknown && byte === 255;
|
|
8436
|
+
}
|
|
8437
|
+
p += sizeLen;
|
|
8438
|
+
return { id: id2, body: p, end: unknown ? to : Math.min(p + size2, to) };
|
|
8439
|
+
}
|
|
8440
|
+
function vintLen(first) {
|
|
8441
|
+
if (first === void 0 || first === 0) return 0;
|
|
8442
|
+
let len = 1;
|
|
8443
|
+
for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
|
|
8444
|
+
return len;
|
|
8445
|
+
}
|
|
8446
|
+
function uint(b, el) {
|
|
8447
|
+
let value = 0;
|
|
8448
|
+
for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
|
|
8449
|
+
value = value * 256 + (b[at] ?? 0);
|
|
8450
|
+
return value;
|
|
8451
|
+
}
|
|
8452
|
+
function float(b, el) {
|
|
8453
|
+
const width = el.end - el.body;
|
|
8454
|
+
if (width === 4) return b.readFloatBE(el.body);
|
|
8455
|
+
if (width === 8) return b.readDoubleBE(el.body);
|
|
8456
|
+
return void 0;
|
|
8457
|
+
}
|
|
8458
|
+
function sane(seconds) {
|
|
8459
|
+
return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
|
|
8460
|
+
}
|
|
8461
|
+
async function attachClips(source, clips, dir) {
|
|
8462
|
+
if (clips.length === 0) return source;
|
|
8463
|
+
const assets = resolve(dir);
|
|
8464
|
+
await mkdir4(assets, { recursive: true });
|
|
8465
|
+
const figures = [...source.figures];
|
|
8466
|
+
for (const clip3 of clips) {
|
|
8467
|
+
const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
|
|
8468
|
+
const poster = clip3.poster ? await adopt(clip3.poster, assets) : void 0;
|
|
8469
|
+
if (src === void 0 && poster === void 0) continue;
|
|
8470
|
+
const at = clip3.poster === "" ? -1 : figures.findIndex((f) => f.src === clip3.poster || f.src === basename2(clip3.poster));
|
|
8471
|
+
const kept = at === -1 ? void 0 : figures[at];
|
|
8472
|
+
const figure = figureSchema.parse({
|
|
8473
|
+
id: kept?.id ?? freeId(figures),
|
|
8474
|
+
kind: "clip",
|
|
8475
|
+
// The file when we hold it, and the still when we do not: `claim-figure`
|
|
8476
|
+
// draws `poster` for a clip that carries an `href` and plays `src` for one
|
|
8477
|
+
// that does not, and `src` is required either way.
|
|
8478
|
+
src: src ?? poster,
|
|
8479
|
+
caption: kept?.caption || clip3.caption,
|
|
8480
|
+
width: clip3.width,
|
|
8481
|
+
height: clip3.height,
|
|
8482
|
+
...kept?.sectionId === void 0 ? {} : { sectionId: kept.sectionId },
|
|
8483
|
+
...kept?.mention === void 0 ? {} : { mention: kept.mention },
|
|
8484
|
+
...poster === void 0 ? {} : { poster },
|
|
8485
|
+
...clip3.seconds === void 0 ? {} : { seconds: clip3.seconds },
|
|
8486
|
+
...clip3.href === "" ? {} : { href: clip3.href }
|
|
8487
|
+
});
|
|
8488
|
+
if (at === -1) figures.push(figure);
|
|
8489
|
+
else figures[at] = figure;
|
|
8490
|
+
}
|
|
8491
|
+
return { ...source, figures };
|
|
8492
|
+
}
|
|
8493
|
+
async function adopt(file, dir) {
|
|
8494
|
+
const name = basename2(file);
|
|
8495
|
+
await copyFile(file, join4(dir, name));
|
|
8496
|
+
return name;
|
|
8497
|
+
}
|
|
8498
|
+
function freeId(figures) {
|
|
8499
|
+
const taken = new Set(figures.map((f) => f.id));
|
|
8500
|
+
let n3 = figures.length + 1;
|
|
8501
|
+
while (taken.has(`fig${n3}`)) n3 += 1;
|
|
8502
|
+
return `fig${n3}`;
|
|
8503
|
+
}
|
|
8504
|
+
function toMarkdown(blocks) {
|
|
8505
|
+
const out = [];
|
|
8506
|
+
for (const item of blocks) {
|
|
8507
|
+
switch (item.kind) {
|
|
8508
|
+
case "heading":
|
|
8509
|
+
out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
|
|
8510
|
+
break;
|
|
8511
|
+
case "paragraph":
|
|
8512
|
+
out.push(inline(item.text));
|
|
8513
|
+
break;
|
|
8514
|
+
case "code":
|
|
8515
|
+
out.push(fenced(item.text));
|
|
8516
|
+
break;
|
|
8517
|
+
case "list":
|
|
8518
|
+
out.push(
|
|
8519
|
+
item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
|
|
8520
|
+
);
|
|
8521
|
+
break;
|
|
8522
|
+
case "table":
|
|
8523
|
+
out.push(pipes(item.columns, item.rows));
|
|
8524
|
+
break;
|
|
8525
|
+
case "image":
|
|
8526
|
+
out.push(`})`);
|
|
8527
|
+
if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
|
|
8528
|
+
break;
|
|
8529
|
+
case "video": {
|
|
8530
|
+
if (item.poster) {
|
|
8531
|
+
out.push(`})`);
|
|
8532
|
+
if (item.caption) out.push(`*${inline(item.caption)}*`);
|
|
8533
|
+
} else if (item.caption) {
|
|
8534
|
+
out.push(inline(item.caption));
|
|
8535
|
+
}
|
|
8536
|
+
const link = item.href || item.src;
|
|
8537
|
+
if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
|
|
8538
|
+
break;
|
|
8539
|
+
}
|
|
8540
|
+
}
|
|
8541
|
+
}
|
|
8542
|
+
return `${out.filter((block) => block !== "").join("\n\n")}
|
|
8543
|
+
`;
|
|
8544
|
+
}
|
|
8545
|
+
function inline(text2) {
|
|
8546
|
+
return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
|
|
8547
|
+
}
|
|
8548
|
+
function destination(path2) {
|
|
8549
|
+
if (/[<>]/.test(path2)) {
|
|
8550
|
+
throw new Error(
|
|
8551
|
+
`cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
|
|
8552
|
+
);
|
|
8553
|
+
}
|
|
8554
|
+
return /[\s()]/.test(path2) ? `<${path2}>` : path2;
|
|
8555
|
+
}
|
|
8556
|
+
function pipes(columns2, rows) {
|
|
8557
|
+
const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
|
|
8558
|
+
const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
|
|
8559
|
+
const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
|
|
8560
|
+
return [cells(columns2), rule, ...rows.map(cells)].join("\n");
|
|
8561
|
+
}
|
|
8562
|
+
function fenced(code) {
|
|
8563
|
+
const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
|
|
8564
|
+
const fence = "`".repeat(Math.max(3, ...runs));
|
|
8565
|
+
return `${fence}
|
|
8566
|
+
${code}
|
|
8567
|
+
${fence}`;
|
|
6170
8568
|
}
|
|
6171
8569
|
|
|
6172
8570
|
// src/plan/codex.ts
|
|
6173
|
-
import { spawn } from "node:child_process";
|
|
6174
|
-
import { mkdtemp, readFile as
|
|
8571
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
8572
|
+
import { mkdtemp, readFile as readFile6, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
6175
8573
|
import { tmpdir } from "node:os";
|
|
6176
|
-
import { join as
|
|
6177
|
-
import { z as
|
|
8574
|
+
import { join as join5 } from "node:path";
|
|
8575
|
+
import { z as z3 } from "zod";
|
|
6178
8576
|
|
|
6179
8577
|
// src/plan/arc.ts
|
|
6180
8578
|
var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
|
|
@@ -6987,9 +9385,28 @@ function renderSource(source) {
|
|
|
6987
9385
|
} else {
|
|
6988
9386
|
const n3 = source.figures.length;
|
|
6989
9387
|
out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
|
|
9388
|
+
if (source.figures.some((f) => f.kind === "clip")) {
|
|
9389
|
+
out.push(
|
|
9390
|
+
"",
|
|
9391
|
+
"A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
|
|
9392
|
+
"figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
|
|
9393
|
+
"same reason: it is the picture that carries the point.",
|
|
9394
|
+
"WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
|
|
9395
|
+
"holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
|
|
9396
|
+
"they press play, and the rendered video shows that same frame unless the clip is",
|
|
9397
|
+
"a file this deck actually holds. A clip listed as watchable only at a link is a",
|
|
9398
|
+
"page whose video we could not download, and its still is all any format will ever",
|
|
9399
|
+
"show. So write the beat for the FRAME: claim what the picture states, and never",
|
|
9400
|
+
"narrate a motion the still does not."
|
|
9401
|
+
);
|
|
9402
|
+
}
|
|
6990
9403
|
const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
|
|
6991
9404
|
for (const f of source.figures) {
|
|
6992
|
-
|
|
9405
|
+
const size2 = 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}`;
|
|
9406
|
+
out.push("", `[figure ${f.id}] ${size2} \u2014 ${f.caption}`);
|
|
9407
|
+
if (f.kind === "clip" && f.href) {
|
|
9408
|
+
out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
|
|
9409
|
+
}
|
|
6993
9410
|
const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
|
|
6994
9411
|
if (f.sectionId !== void 0) {
|
|
6995
9412
|
out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
|
|
@@ -7137,7 +9554,7 @@ function assertInsideResolves(storyboard, source) {
|
|
|
7137
9554
|
let parts;
|
|
7138
9555
|
try {
|
|
7139
9556
|
const sid = `s${i}`;
|
|
7140
|
-
const scene = emitScene(previous, { source, format, theme: ink, sid });
|
|
9557
|
+
const scene = emitScene(previous, { source, format, theme: ink, sid, start: 0 });
|
|
7141
9558
|
drawn = enterableIds(sid, scene.html).map((id2) => id2.replace(`${sid}-`, ""));
|
|
7142
9559
|
parts = scene.parts;
|
|
7143
9560
|
} catch {
|
|
@@ -7165,7 +9582,7 @@ function assertInsideResolves(storyboard, source) {
|
|
|
7165
9582
|
}
|
|
7166
9583
|
|
|
7167
9584
|
// src/plan/codex.ts
|
|
7168
|
-
var
|
|
9585
|
+
var DEFAULT_TIMEOUT_MS2 = 10 * 6e4;
|
|
7169
9586
|
var UNSUPPORTED = /* @__PURE__ */ new Set([
|
|
7170
9587
|
"$schema",
|
|
7171
9588
|
"default",
|
|
@@ -7240,26 +9657,26 @@ function hideFromPlanner(node, hidden) {
|
|
|
7240
9657
|
}
|
|
7241
9658
|
function schemaFor(prefs) {
|
|
7242
9659
|
return hideFromPlanner(
|
|
7243
|
-
forStructuredOutput(
|
|
9660
|
+
forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
|
|
7244
9661
|
plannerInvisible(prefs)
|
|
7245
9662
|
);
|
|
7246
9663
|
}
|
|
7247
9664
|
var SCHEMA = schemaFor({ genre: "general" });
|
|
7248
9665
|
async function codexPlanner(source, opts = {}) {
|
|
7249
9666
|
const prefs = opts.prefs ?? prefsSchema.parse({});
|
|
7250
|
-
const dir = await mkdtemp(
|
|
9667
|
+
const dir = await mkdtemp(join5(tmpdir(), "decksmith-plan-"));
|
|
7251
9668
|
try {
|
|
7252
|
-
const schemaPath =
|
|
7253
|
-
const outPath =
|
|
7254
|
-
await
|
|
9669
|
+
const schemaPath = join5(dir, "storyboard.schema.json");
|
|
9670
|
+
const outPath = join5(dir, "storyboard.json");
|
|
9671
|
+
await writeFile5(schemaPath, JSON.stringify(schemaFor(prefs)));
|
|
7255
9672
|
await (opts.run ?? runCodex)({
|
|
7256
9673
|
prompt: buildPrompt(source, prefs),
|
|
7257
9674
|
schemaPath,
|
|
7258
9675
|
outPath,
|
|
7259
9676
|
...opts.model === void 0 ? {} : { model: opts.model },
|
|
7260
|
-
timeoutMs: opts.timeoutMs ??
|
|
9677
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2
|
|
7261
9678
|
});
|
|
7262
|
-
const raw2 = await
|
|
9679
|
+
const raw2 = await readFile6(outPath, "utf8").catch(() => "");
|
|
7263
9680
|
if (raw2.trim() === "") {
|
|
7264
9681
|
throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
|
|
7265
9682
|
}
|
|
@@ -7290,7 +9707,7 @@ ${issues}`);
|
|
|
7290
9707
|
assertRefsResolve(result.data, source, { pending: prefs.images.enabled ? "allow" : "refuse" });
|
|
7291
9708
|
return result.data;
|
|
7292
9709
|
} finally {
|
|
7293
|
-
await
|
|
9710
|
+
await rm3(dir, { recursive: true, force: true });
|
|
7294
9711
|
}
|
|
7295
9712
|
}
|
|
7296
9713
|
function buildPrompt(source, prefs) {
|
|
@@ -7332,8 +9749,8 @@ function codexCommand(args) {
|
|
|
7332
9749
|
}
|
|
7333
9750
|
function runCodex(args) {
|
|
7334
9751
|
const { argv, env } = codexCommand(args);
|
|
7335
|
-
return new Promise((
|
|
7336
|
-
const child =
|
|
9752
|
+
return new Promise((resolve6, reject) => {
|
|
9753
|
+
const child = spawn2("codex", argv, {
|
|
7337
9754
|
stdio: ["pipe", "ignore", "pipe"],
|
|
7338
9755
|
...env === void 0 ? {} : { env }
|
|
7339
9756
|
});
|
|
@@ -7355,7 +9772,7 @@ function runCodex(args) {
|
|
|
7355
9772
|
});
|
|
7356
9773
|
child.on("close", (code) => {
|
|
7357
9774
|
clearTimeout(timer);
|
|
7358
|
-
if (code === 0) return
|
|
9775
|
+
if (code === 0) return resolve6();
|
|
7359
9776
|
reject(
|
|
7360
9777
|
new Error(`codex exec exited ${code}.
|
|
7361
9778
|
${stderr.trim().split("\n").slice(-8).join("\n")}`)
|
|
@@ -7366,16 +9783,16 @@ ${stderr.trim().split("\n").slice(-8).join("\n")}`)
|
|
|
7366
9783
|
}
|
|
7367
9784
|
|
|
7368
9785
|
// src/images/illustrate.ts
|
|
7369
|
-
import { createHash as
|
|
7370
|
-
import { mkdir as
|
|
7371
|
-
import { join as
|
|
9786
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
9787
|
+
import { mkdir as mkdir5, readFile as readFile8, rename, rm as rm5, writeFile as writeFile7 } from "node:fs/promises";
|
|
9788
|
+
import { join as join7 } from "node:path";
|
|
7372
9789
|
|
|
7373
9790
|
// src/images/providers.ts
|
|
7374
|
-
import { createHash as
|
|
7375
|
-
import { mkdtemp as mkdtemp2, readFile as
|
|
9791
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
9792
|
+
import { mkdtemp as mkdtemp2, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
|
|
7376
9793
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
7377
|
-
import { join as
|
|
7378
|
-
import { z as
|
|
9794
|
+
import { join as join6, resolve as resolve2 } from "node:path";
|
|
9795
|
+
import { z as z4 } from "zod";
|
|
7379
9796
|
var SIZE = {
|
|
7380
9797
|
landscape: { width: 1536, height: 1024 },
|
|
7381
9798
|
square: { width: 1024, height: 1024 },
|
|
@@ -7398,11 +9815,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
|
7398
9815
|
var DEFAULT_MODEL = "gpt-image-2";
|
|
7399
9816
|
var REQUEST_TIMEOUT_MS = 12e4;
|
|
7400
9817
|
var MAX_BYTES = 16 * 1024 * 1024;
|
|
7401
|
-
var generatedSchema =
|
|
7402
|
-
data:
|
|
9818
|
+
var generatedSchema = z4.object({
|
|
9819
|
+
data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
|
|
7403
9820
|
});
|
|
7404
|
-
var failedSchema =
|
|
7405
|
-
error:
|
|
9821
|
+
var failedSchema = z4.object({
|
|
9822
|
+
error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
|
|
7406
9823
|
});
|
|
7407
9824
|
function openaiImages(opts) {
|
|
7408
9825
|
const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
@@ -7427,8 +9844,8 @@ function openaiImages(opts) {
|
|
|
7427
9844
|
}),
|
|
7428
9845
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7429
9846
|
});
|
|
7430
|
-
const body = await
|
|
7431
|
-
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${
|
|
9847
|
+
const body = await readCapped2(res);
|
|
9848
|
+
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
|
|
7432
9849
|
const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
|
|
7433
9850
|
if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
|
|
7434
9851
|
if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
|
|
@@ -7441,14 +9858,14 @@ function openaiImages(opts) {
|
|
|
7441
9858
|
redirect: "error",
|
|
7442
9859
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7443
9860
|
});
|
|
7444
|
-
const bytes = await
|
|
9861
|
+
const bytes = await readCapped2(picture);
|
|
7445
9862
|
if (!picture.ok)
|
|
7446
9863
|
throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
|
|
7447
9864
|
return raster(bytes, "openai images");
|
|
7448
9865
|
}
|
|
7449
9866
|
};
|
|
7450
9867
|
}
|
|
7451
|
-
function
|
|
9868
|
+
function reason2(body) {
|
|
7452
9869
|
const error = failedSchema.safeParse(parseJson(body)).data?.error;
|
|
7453
9870
|
const code = error?.code ?? error?.type;
|
|
7454
9871
|
return code ? ` (${code})` : "";
|
|
@@ -7460,7 +9877,7 @@ function parseJson(body) {
|
|
|
7460
9877
|
return void 0;
|
|
7461
9878
|
}
|
|
7462
9879
|
}
|
|
7463
|
-
async function
|
|
9880
|
+
async function readCapped2(res) {
|
|
7464
9881
|
const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
|
|
7465
9882
|
if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
|
|
7466
9883
|
const reader = res.body?.getReader();
|
|
@@ -7490,10 +9907,10 @@ var ANSWER_SCHEMA = {
|
|
|
7490
9907
|
required: ["ok", "file", "reason"],
|
|
7491
9908
|
additionalProperties: false
|
|
7492
9909
|
};
|
|
7493
|
-
var answerSchema =
|
|
7494
|
-
ok:
|
|
7495
|
-
file:
|
|
7496
|
-
reason:
|
|
9910
|
+
var answerSchema = z4.object({
|
|
9911
|
+
ok: z4.boolean(),
|
|
9912
|
+
file: z4.string().nullish(),
|
|
9913
|
+
reason: z4.string().nullish()
|
|
7497
9914
|
});
|
|
7498
9915
|
function codexImages(opts = {}) {
|
|
7499
9916
|
const run4 = opts.run ?? runCodex;
|
|
@@ -7506,11 +9923,11 @@ function codexImages(opts = {}) {
|
|
|
7506
9923
|
async check() {
|
|
7507
9924
|
},
|
|
7508
9925
|
async generate(req) {
|
|
7509
|
-
const dir = await mkdtemp2(
|
|
9926
|
+
const dir = await mkdtemp2(join6(tmpdir2(), "decksmith-image-"));
|
|
7510
9927
|
try {
|
|
7511
|
-
const schemaPath =
|
|
7512
|
-
const outPath =
|
|
7513
|
-
await
|
|
9928
|
+
const schemaPath = join6(dir, "answer.schema.json");
|
|
9929
|
+
const outPath = join6(dir, "answer.json");
|
|
9930
|
+
await writeFile6(schemaPath, JSON.stringify(ANSWER_SCHEMA));
|
|
7514
9931
|
await run4({
|
|
7515
9932
|
prompt: codexPrompt(req),
|
|
7516
9933
|
schemaPath,
|
|
@@ -7519,7 +9936,7 @@ function codexImages(opts = {}) {
|
|
|
7519
9936
|
cwd: dir,
|
|
7520
9937
|
sandbox: "workspace-write"
|
|
7521
9938
|
});
|
|
7522
|
-
const raw2 = await
|
|
9939
|
+
const raw2 = await readFile7(outPath, "utf8").catch(() => "");
|
|
7523
9940
|
const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
|
|
7524
9941
|
if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
|
|
7525
9942
|
if (!answer.data.ok) {
|
|
@@ -7527,15 +9944,15 @@ function codexImages(opts = {}) {
|
|
|
7527
9944
|
`codex could not generate a picture: ${answer.data.reason ?? "no reason given"}`
|
|
7528
9945
|
);
|
|
7529
9946
|
}
|
|
7530
|
-
const candidates2 = [
|
|
7531
|
-
if (answer.data.file) candidates2.push(
|
|
9947
|
+
const candidates2 = [join6(dir, "picture.png")];
|
|
9948
|
+
if (answer.data.file) candidates2.push(resolve2(dir, answer.data.file));
|
|
7532
9949
|
for (const path2 of candidates2) {
|
|
7533
|
-
const bytes = await
|
|
9950
|
+
const bytes = await readFile7(path2).catch(() => null);
|
|
7534
9951
|
if (bytes) return raster(bytes, "codex");
|
|
7535
9952
|
}
|
|
7536
9953
|
throw new Error("codex said ok but wrote no picture.png");
|
|
7537
9954
|
} finally {
|
|
7538
|
-
await
|
|
9955
|
+
await rm4(dir, { recursive: true, force: true });
|
|
7539
9956
|
}
|
|
7540
9957
|
}
|
|
7541
9958
|
};
|
|
@@ -7570,7 +9987,7 @@ function mulberry32(seed) {
|
|
|
7570
9987
|
}
|
|
7571
9988
|
function drawSvg(req) {
|
|
7572
9989
|
const { width: W, height: H } = SIZE[req.aspect];
|
|
7573
|
-
const seed =
|
|
9990
|
+
const seed = createHash6("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
|
|
7574
9991
|
const rand = mulberry32(seed.readUInt32BE(0));
|
|
7575
9992
|
const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
|
|
7576
9993
|
const count = 6 + Math.floor(rand() * 5);
|
|
@@ -7601,11 +10018,6 @@ function drawSvg(req) {
|
|
|
7601
10018
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
|
|
7602
10019
|
`;
|
|
7603
10020
|
}
|
|
7604
|
-
function svgSize(bytes) {
|
|
7605
|
-
const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
|
|
7606
|
-
if (!m) throw new Error("svg has no viewBox");
|
|
7607
|
-
return { width: Number(m[1]), height: Number(m[2]) };
|
|
7608
|
-
}
|
|
7609
10021
|
function toolSvg() {
|
|
7610
10022
|
return {
|
|
7611
10023
|
id: "svg",
|
|
@@ -7649,7 +10061,7 @@ function imageChain(images, backend) {
|
|
|
7649
10061
|
}
|
|
7650
10062
|
|
|
7651
10063
|
// src/images/illustrate.ts
|
|
7652
|
-
var
|
|
10064
|
+
var EXT2 = {
|
|
7653
10065
|
"image/png": ".png",
|
|
7654
10066
|
"image/jpeg": ".jpg",
|
|
7655
10067
|
"image/svg+xml": ".svg"
|
|
@@ -7667,7 +10079,7 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7667
10079
|
const tool = last?.id === "svg" ? last : toolSvg();
|
|
7668
10080
|
if (tool !== last) chain.push(tool);
|
|
7669
10081
|
const dropped = /* @__PURE__ */ new Set();
|
|
7670
|
-
if (pending.length > 0) await
|
|
10082
|
+
if (pending.length > 0) await mkdir5(opts.assetsDir, { recursive: true });
|
|
7671
10083
|
for (const [i, slot] of pending.entries()) {
|
|
7672
10084
|
let rungs = chain;
|
|
7673
10085
|
if (i >= images.max) {
|
|
@@ -7687,6 +10099,9 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7687
10099
|
const picture = await draw(provider, req, name, opts.assetsDir);
|
|
7688
10100
|
const figure = {
|
|
7689
10101
|
id: slot.figureId,
|
|
10102
|
+
// An illustration is always a still: every provider draws a picture,
|
|
10103
|
+
// and there is no rung that returns a video.
|
|
10104
|
+
kind: "image",
|
|
7690
10105
|
src: picture.src,
|
|
7691
10106
|
caption: slot.brief.caption,
|
|
7692
10107
|
width: picture.width,
|
|
@@ -7705,8 +10120,8 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7705
10120
|
} catch (err) {
|
|
7706
10121
|
const after = live[j + 1];
|
|
7707
10122
|
if (!after) throw err;
|
|
7708
|
-
const
|
|
7709
|
-
step(`illustrate: ${slot.label} via ${provider.id} failed (${
|
|
10123
|
+
const why3 = err instanceof Error ? err.message : String(err);
|
|
10124
|
+
step(`illustrate: ${slot.label} via ${provider.id} failed (${why3}); trying ${after.id}`);
|
|
7710
10125
|
dropped.add(provider.id);
|
|
7711
10126
|
}
|
|
7712
10127
|
}
|
|
@@ -7756,36 +10171,36 @@ function slots(storyboard, known) {
|
|
|
7756
10171
|
return out;
|
|
7757
10172
|
}
|
|
7758
10173
|
function cacheKey(providerId, req) {
|
|
7759
|
-
return
|
|
10174
|
+
return createHash7("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
|
|
7760
10175
|
}
|
|
7761
10176
|
async function draw(provider, req, name, dir) {
|
|
7762
|
-
for (const ext of Object.values(
|
|
10177
|
+
for (const ext of Object.values(EXT2)) {
|
|
7763
10178
|
const src2 = `${name}${ext}`;
|
|
7764
|
-
const bytes = await
|
|
7765
|
-
if (bytes) return { src: src2, ...
|
|
10179
|
+
const bytes = await readFile8(join7(dir, src2)).catch(() => null);
|
|
10180
|
+
if (bytes) return { src: src2, ...sizeOf2(bytes, ext), cached: true };
|
|
7766
10181
|
}
|
|
7767
10182
|
await provider.check();
|
|
7768
10183
|
const img = await provider.generate(req);
|
|
7769
|
-
const src = `${name}${
|
|
7770
|
-
const tmp =
|
|
10184
|
+
const src = `${name}${EXT2[img.mime]}`;
|
|
10185
|
+
const tmp = join7(dir, `.${name}.tmp`);
|
|
7771
10186
|
try {
|
|
7772
|
-
await
|
|
7773
|
-
await rename(tmp,
|
|
10187
|
+
await writeFile7(tmp, img.bytes);
|
|
10188
|
+
await rename(tmp, join7(dir, src));
|
|
7774
10189
|
} finally {
|
|
7775
|
-
await
|
|
10190
|
+
await rm5(tmp, { force: true });
|
|
7776
10191
|
}
|
|
7777
10192
|
return { src, width: img.width, height: img.height, cached: false };
|
|
7778
10193
|
}
|
|
7779
|
-
function
|
|
10194
|
+
function sizeOf2(bytes, ext) {
|
|
7780
10195
|
return ext === ".svg" ? svgSize(bytes) : imageSize(bytes);
|
|
7781
10196
|
}
|
|
7782
10197
|
|
|
7783
10198
|
// src/narrate/tts.ts
|
|
7784
|
-
import { spawn as
|
|
7785
|
-
import { createHash as
|
|
7786
|
-
import { mkdir as
|
|
7787
|
-
import { homedir } from "node:os";
|
|
7788
|
-
import { join as
|
|
10199
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
10200
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
10201
|
+
import { mkdir as mkdir6, readFile as readFile9, rm as rm6, writeFile as writeFile8 } from "node:fs/promises";
|
|
10202
|
+
import { homedir as homedir2 } from "node:os";
|
|
10203
|
+
import { join as join8 } from "node:path";
|
|
7789
10204
|
var MISSING = [
|
|
7790
10205
|
"edge-tts is not installed, so narration cannot be synthesised.",
|
|
7791
10206
|
"",
|
|
@@ -7796,14 +10211,14 @@ var MISSING = [
|
|
|
7796
10211
|
].join("\n");
|
|
7797
10212
|
function candidates() {
|
|
7798
10213
|
const env = process.env.DECKSMITH_EDGE_TTS?.trim();
|
|
7799
|
-
const home =
|
|
10214
|
+
const home = homedir2();
|
|
7800
10215
|
return [
|
|
7801
10216
|
...env ? [[env]] : [],
|
|
7802
10217
|
["edge-tts"],
|
|
7803
10218
|
// pip --user on macOS and on Linux respectively. Neither is on PATH by
|
|
7804
10219
|
// default, and both are where this actually lands in practice.
|
|
7805
|
-
[
|
|
7806
|
-
[
|
|
10220
|
+
[join8(home, "Library", "Python", "3.9", "bin", "edge-tts")],
|
|
10221
|
+
[join8(home, ".local", "bin", "edge-tts")],
|
|
7807
10222
|
// Last resort: the module is installed even though its console script is
|
|
7808
10223
|
// nowhere findable, which is the normal state of a pip --user install.
|
|
7809
10224
|
["python3", "-m", "edge_tts"]
|
|
@@ -7817,19 +10232,19 @@ async function answersHelp(argv) {
|
|
|
7817
10232
|
}
|
|
7818
10233
|
var resolved = null;
|
|
7819
10234
|
function resolveEdgeTts(can = answersHelp) {
|
|
7820
|
-
if (can !== answersHelp) return
|
|
7821
|
-
resolved ??=
|
|
10235
|
+
if (can !== answersHelp) return find2(can);
|
|
10236
|
+
resolved ??= find2(can);
|
|
7822
10237
|
return resolved;
|
|
7823
10238
|
}
|
|
7824
|
-
async function
|
|
10239
|
+
async function find2(can) {
|
|
7825
10240
|
for (const argv of candidates()) {
|
|
7826
10241
|
if (await can(argv)) return argv;
|
|
7827
10242
|
}
|
|
7828
10243
|
throw new Error(MISSING);
|
|
7829
10244
|
}
|
|
7830
10245
|
function runArgv(cmd, args) {
|
|
7831
|
-
return new Promise((
|
|
7832
|
-
const child =
|
|
10246
|
+
return new Promise((resolve6) => {
|
|
10247
|
+
const child = spawn3(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
7833
10248
|
let stdout = "";
|
|
7834
10249
|
let stderr = "";
|
|
7835
10250
|
child.stdout.on("data", (b) => {
|
|
@@ -7838,8 +10253,8 @@ function runArgv(cmd, args) {
|
|
|
7838
10253
|
child.stderr.on("data", (b) => {
|
|
7839
10254
|
stderr += b.toString();
|
|
7840
10255
|
});
|
|
7841
|
-
child.on("error", (e) =>
|
|
7842
|
-
child.on("close", (code) =>
|
|
10256
|
+
child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
|
|
10257
|
+
child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
|
|
7843
10258
|
});
|
|
7844
10259
|
}
|
|
7845
10260
|
var edgeTts = {
|
|
@@ -7906,8 +10321,8 @@ function edgeProvider(runner = edgeTts) {
|
|
|
7906
10321
|
async speak(req) {
|
|
7907
10322
|
const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
|
|
7908
10323
|
await runner.speak({ ...req, subs });
|
|
7909
|
-
const raw2 = await
|
|
7910
|
-
await
|
|
10324
|
+
const raw2 = await readFile9(subs, "utf8").catch(() => "");
|
|
10325
|
+
await rm6(subs, { force: true });
|
|
7911
10326
|
const cues = parseCues(raw2);
|
|
7912
10327
|
const measured = await runner.measure(req.audio);
|
|
7913
10328
|
const lastCue = cues.length > 0 ? cues[cues.length - 1].end : 0;
|
|
@@ -7928,7 +10343,7 @@ function resolveProvider(name = process.env.DECKSMITH_TTS ?? "edge-tts") {
|
|
|
7928
10343
|
return make();
|
|
7929
10344
|
}
|
|
7930
10345
|
function cacheKey2(text2, voice, rate, pitch) {
|
|
7931
|
-
return
|
|
10346
|
+
return createHash8("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
|
|
7932
10347
|
}
|
|
7933
10348
|
async function synthesize(text2, opts) {
|
|
7934
10349
|
const rate = opts.rate ?? "+0%";
|
|
@@ -7936,11 +10351,11 @@ async function synthesize(text2, opts) {
|
|
|
7936
10351
|
const provider = opts.provider ?? edgeProvider(opts.runner ?? edgeTts);
|
|
7937
10352
|
const key = cacheKey2(text2, opts.voice, rate, pitch);
|
|
7938
10353
|
const file = `${key}.mp3`;
|
|
7939
|
-
const audio =
|
|
7940
|
-
const sidecar =
|
|
10354
|
+
const audio = join8(opts.dir, file);
|
|
10355
|
+
const sidecar = join8(opts.dir, `${key}.json`);
|
|
7941
10356
|
const cached = await readSidecar(sidecar);
|
|
7942
10357
|
if (cached) return { audio, file, seconds: cached.seconds, cues: cached.cues };
|
|
7943
|
-
await
|
|
10358
|
+
await mkdir6(opts.dir, { recursive: true });
|
|
7944
10359
|
const spoken = await provider.speak({ text: text2, voice: opts.voice, rate, pitch, audio });
|
|
7945
10360
|
const { cues, seconds } = spoken;
|
|
7946
10361
|
const clamped = cues.map((c) => ({
|
|
@@ -7949,13 +10364,13 @@ async function synthesize(text2, opts) {
|
|
|
7949
10364
|
text: c.text
|
|
7950
10365
|
}));
|
|
7951
10366
|
const body = { seconds, cues: clamped, text: text2, voice: opts.voice };
|
|
7952
|
-
await
|
|
10367
|
+
await writeFile8(sidecar, `${JSON.stringify(body, null, 2)}
|
|
7953
10368
|
`);
|
|
7954
10369
|
return { audio, file, seconds, cues: clamped };
|
|
7955
10370
|
}
|
|
7956
10371
|
async function readSidecar(path2) {
|
|
7957
10372
|
try {
|
|
7958
|
-
const parsed = JSON.parse(await
|
|
10373
|
+
const parsed = JSON.parse(await readFile9(path2, "utf8"));
|
|
7959
10374
|
const s = parsed;
|
|
7960
10375
|
return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
|
|
7961
10376
|
} catch {
|
|
@@ -8011,7 +10426,7 @@ function stopCount(holds) {
|
|
|
8011
10426
|
return Math.max(1, usable.size);
|
|
8012
10427
|
}
|
|
8013
10428
|
function stopsFor(beat, source, format, sid = "s1") {
|
|
8014
|
-
const ctx = { source, format, theme: ink, sid };
|
|
10429
|
+
const ctx = { source, format, theme: ink, sid, start: 0 };
|
|
8015
10430
|
try {
|
|
8016
10431
|
return stopCount(emitScene(beat, ctx).holds);
|
|
8017
10432
|
} catch {
|
|
@@ -8050,7 +10465,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
8050
10465
|
for (const [i, beat] of storyboard.beats.entries()) {
|
|
8051
10466
|
const text2 = beat.narration?.trim();
|
|
8052
10467
|
if (!text2) continue;
|
|
8053
|
-
const
|
|
10468
|
+
const segments2 = [];
|
|
8054
10469
|
const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
|
|
8055
10470
|
const plan = planSegments(text2, stops);
|
|
8056
10471
|
for (const [stop, line2] of plan.entries()) {
|
|
@@ -8062,7 +10477,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
8062
10477
|
dir: opts.dir,
|
|
8063
10478
|
runner: opts.runner
|
|
8064
10479
|
});
|
|
8065
|
-
|
|
10480
|
+
segments2.push({
|
|
8066
10481
|
stop,
|
|
8067
10482
|
text: line2,
|
|
8068
10483
|
// Content-addressed and flat, so the path is the filename and the deck
|
|
@@ -8072,14 +10487,14 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
8072
10487
|
cues: speech.cues
|
|
8073
10488
|
});
|
|
8074
10489
|
}
|
|
8075
|
-
if (
|
|
10490
|
+
if (segments2.length > 0) beats[beat.id] = segments2;
|
|
8076
10491
|
}
|
|
8077
10492
|
return { voice, beats };
|
|
8078
10493
|
}
|
|
8079
10494
|
|
|
8080
10495
|
// src/verify/index.ts
|
|
8081
|
-
import { readdir as
|
|
8082
|
-
import { join as
|
|
10496
|
+
import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
|
|
10497
|
+
import { join as join12, relative, sep } from "node:path";
|
|
8083
10498
|
|
|
8084
10499
|
// src/verify/budget.ts
|
|
8085
10500
|
function readCanvas(html) {
|
|
@@ -8185,24 +10600,24 @@ function clock2(seconds) {
|
|
|
8185
10600
|
}
|
|
8186
10601
|
|
|
8187
10602
|
// src/verify/check.ts
|
|
8188
|
-
import { execFile } from "node:child_process";
|
|
8189
|
-
import { readFile as
|
|
8190
|
-
import { join as
|
|
8191
|
-
import { promisify } from "node:util";
|
|
8192
|
-
var
|
|
8193
|
-
var
|
|
10603
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
10604
|
+
import { readFile as readFile10 } from "node:fs/promises";
|
|
10605
|
+
import { join as join9 } from "node:path";
|
|
10606
|
+
import { promisify as promisify2 } from "node:util";
|
|
10607
|
+
var run2 = promisify2(execFile2);
|
|
10608
|
+
var DEFAULT_TIMEOUT_MS3 = 24e4;
|
|
8194
10609
|
var DEFAULT_SAMPLES = 9;
|
|
8195
10610
|
var GATES = ["lint", "runtime", "layout", "motion", "contrast"];
|
|
8196
10611
|
var SEVERITIES = /* @__PURE__ */ new Set(["error", "warning", "info"]);
|
|
8197
10612
|
async function check(dir, opts = {}) {
|
|
8198
|
-
const timeoutMs = opts.timeoutMs ??
|
|
10613
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
|
|
8199
10614
|
const args = ["hyperframes", "check", "--json", dir];
|
|
8200
10615
|
if (opts.snapshots) args.push("--snapshots");
|
|
8201
10616
|
const started = Date.now();
|
|
8202
10617
|
const { transit, duration } = await readComposition(dir);
|
|
8203
10618
|
if (opts.at?.length) args.push(`--at=${sampleTimes(opts.at, duration).join(",")}`);
|
|
8204
10619
|
try {
|
|
8205
|
-
const { stdout, stderr } = await
|
|
10620
|
+
const { stdout, stderr } = await run2("npx", args, { timeout: timeoutMs, maxBuffer: 32 << 20 });
|
|
8206
10621
|
return interpret(stdout, stderr, { timeoutMs, elapsed: Date.now() - started, transit });
|
|
8207
10622
|
} catch (err) {
|
|
8208
10623
|
const e = err;
|
|
@@ -8217,7 +10632,7 @@ async function check(dir, opts = {}) {
|
|
|
8217
10632
|
async function readComposition(dir) {
|
|
8218
10633
|
let html;
|
|
8219
10634
|
try {
|
|
8220
|
-
html = await
|
|
10635
|
+
html = await readFile10(join9(dir, "index.html"), "utf8");
|
|
8221
10636
|
} catch {
|
|
8222
10637
|
return { transit: [], duration: 0 };
|
|
8223
10638
|
}
|
|
@@ -8301,11 +10716,11 @@ function regrade({ f, time, selector }, transit = []) {
|
|
|
8301
10716
|
(w) => time > w.t0 && time < w.t1 && (named === void 0 || named === w.sid)
|
|
8302
10717
|
);
|
|
8303
10718
|
if (hit) {
|
|
8304
|
-
const
|
|
10719
|
+
const why3 = named === hit.sid ? `no stop of ${hit.sid} is inside this window` : "the element names no scene, so this is excused on time alone";
|
|
8305
10720
|
return {
|
|
8306
10721
|
...f,
|
|
8307
10722
|
severity: "info",
|
|
8308
|
-
message: `${f.message} \u2014 accepted: mid-camera-move, ${
|
|
10723
|
+
message: `${f.message} \u2014 accepted: mid-camera-move, ${why3}.`
|
|
8309
10724
|
};
|
|
8310
10725
|
}
|
|
8311
10726
|
return { ...f, severity: "error" };
|
|
@@ -8318,7 +10733,7 @@ function regrade({ f, time, selector }, transit = []) {
|
|
|
8318
10733
|
var OFF_CANVAS = /* @__PURE__ */ new Set(["canvas_overflow", "panel_out_of_canvas", "text_occluded"]);
|
|
8319
10734
|
function toFinding(gate, f) {
|
|
8320
10735
|
const severity = typeof f.severity === "string" && SEVERITIES.has(f.severity) ? f.severity : "warning";
|
|
8321
|
-
const
|
|
10736
|
+
const message2 = str(f.message) ?? "(no message)";
|
|
8322
10737
|
const time = typeof f.time === "number" && f.time > 0 ? f.time : void 0;
|
|
8323
10738
|
const at = time !== void 0 ? `t=${time}s` : void 0;
|
|
8324
10739
|
const where2 = [str(f.selector) ?? str(f.containerSelector), at].filter(Boolean).join(" ");
|
|
@@ -8329,7 +10744,7 @@ function toFinding(gate, f) {
|
|
|
8329
10744
|
rule: str(f.code) ?? "unknown",
|
|
8330
10745
|
// "Text extends outside the composition canvas." is unactionable without the
|
|
8331
10746
|
// element it happened to, and Finding has nowhere else to put it.
|
|
8332
|
-
message: where2 ? `${
|
|
10747
|
+
message: where2 ? `${message2} [${where2}]` : message2
|
|
8333
10748
|
},
|
|
8334
10749
|
time,
|
|
8335
10750
|
selector: str(f.selector) ?? str(f.containerSelector)
|
|
@@ -8343,8 +10758,8 @@ function staticGuardFindings(stderr) {
|
|
|
8343
10758
|
message: line2.slice("[StaticGuard]".length).trim()
|
|
8344
10759
|
}));
|
|
8345
10760
|
}
|
|
8346
|
-
function tooling(rule,
|
|
8347
|
-
return { severity: "error", gate: "check", rule, message };
|
|
10761
|
+
function tooling(rule, message2) {
|
|
10762
|
+
return { severity: "error", gate: "check", rule, message: message2 };
|
|
8348
10763
|
}
|
|
8349
10764
|
function readJson(stdout) {
|
|
8350
10765
|
const first = stdout.indexOf("{");
|
|
@@ -8368,87 +10783,8 @@ function tail(s) {
|
|
|
8368
10783
|
}
|
|
8369
10784
|
|
|
8370
10785
|
// src/verify/fidelity.ts
|
|
8371
|
-
import { readFile as
|
|
8372
|
-
import { join as
|
|
8373
|
-
|
|
8374
|
-
// src/render/capture.ts
|
|
8375
|
-
import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
|
|
8376
|
-
import { createRequire } from "node:module";
|
|
8377
|
-
import { homedir as homedir2 } from "node:os";
|
|
8378
|
-
import { join as join8 } from "node:path";
|
|
8379
|
-
import { pathToFileURL } from "node:url";
|
|
8380
|
-
async function chromePath(need = "open the deck with") {
|
|
8381
|
-
const explicit = process.env.DECKSMITH_CHROME || process.env.CHROME_PATH;
|
|
8382
|
-
if (explicit) return explicit;
|
|
8383
|
-
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
8384
|
-
const cacheDir = process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer");
|
|
8385
|
-
const installed = await getInstalledBrowsers({ cacheDir }).catch(() => []);
|
|
8386
|
-
const found = installed.find((b) => b.browser === "chrome-headless-shell") ?? installed.find((b) => b.browser === "chrome");
|
|
8387
|
-
if (found) return found.executablePath;
|
|
8388
|
-
throw new Error(
|
|
8389
|
-
`no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
|
|
8390
|
-
);
|
|
8391
|
-
}
|
|
8392
|
-
function runtimePath() {
|
|
8393
|
-
return createRequire(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
|
|
8394
|
-
}
|
|
8395
|
-
function renderSeek(t2) {
|
|
8396
|
-
const player = window.__player;
|
|
8397
|
-
player.renderSeek(t2, { suppressEvents: true });
|
|
8398
|
-
}
|
|
8399
|
-
async function openDeck(dir, opts = {}) {
|
|
8400
|
-
const index = join8(dir, "index.html");
|
|
8401
|
-
const html = await readFile8(index, "utf8").catch(() => null);
|
|
8402
|
-
if (html === null) throw new Error(`no index.html in ${dir}`);
|
|
8403
|
-
const width = Number(/data-width="(\d+)"/.exec(html)?.[1] ?? 0);
|
|
8404
|
-
const height = Number(/data-height="(\d+)"/.exec(html)?.[1] ?? 0);
|
|
8405
|
-
if (!width || !height) throw new Error("index.html declares no canvas size");
|
|
8406
|
-
const timeout = opts.timeoutMs ?? 6e4;
|
|
8407
|
-
const { default: puppeteer } = await import("puppeteer-core");
|
|
8408
|
-
const browser = await puppeteer.launch({
|
|
8409
|
-
executablePath: await chromePath(),
|
|
8410
|
-
headless: true,
|
|
8411
|
-
// A retina host would otherwise hand back a 2x frame, whose clip is not the
|
|
8412
|
-
// renderer's.
|
|
8413
|
-
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
8414
|
-
});
|
|
8415
|
-
try {
|
|
8416
|
-
const page = await browser.newPage();
|
|
8417
|
-
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
|
|
8418
|
-
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
|
8419
|
-
await page.goto(pathToFileURL(index).href, { waitUntil: "load", timeout });
|
|
8420
|
-
await page.addScriptTag({ path: runtimePath() });
|
|
8421
|
-
await page.waitForFunction(
|
|
8422
|
-
// The composition registers a paused timeline per scene and a spanning
|
|
8423
|
-
// `main` that carries no motion, so seeking `main` directly moves nothing.
|
|
8424
|
-
// Only the runtime knows the per-scene offsets; wait for it.
|
|
8425
|
-
"typeof window.__player?.renderSeek === 'function'",
|
|
8426
|
-
{ timeout }
|
|
8427
|
-
);
|
|
8428
|
-
await page.evaluate(() => document.fonts.ready);
|
|
8429
|
-
const cdp = await page.createCDPSession();
|
|
8430
|
-
return {
|
|
8431
|
-
width,
|
|
8432
|
-
height,
|
|
8433
|
-
page,
|
|
8434
|
-
seek: (t2) => page.evaluate(renderSeek, t2),
|
|
8435
|
-
shoot: async () => {
|
|
8436
|
-
const shot = await cdp.send("Page.captureScreenshot", {
|
|
8437
|
-
format: "png",
|
|
8438
|
-
fromSurface: true,
|
|
8439
|
-
captureBeyondViewport: false,
|
|
8440
|
-
clip: { x: 0, y: 0, width, height, scale: 1 }
|
|
8441
|
-
});
|
|
8442
|
-
return Buffer.from(shot.data, "base64");
|
|
8443
|
-
},
|
|
8444
|
-
close: () => browser.close()
|
|
8445
|
-
};
|
|
8446
|
-
} catch (err) {
|
|
8447
|
-
await browser.close().catch(() => {
|
|
8448
|
-
});
|
|
8449
|
-
throw err;
|
|
8450
|
-
}
|
|
8451
|
-
}
|
|
10786
|
+
import { readFile as readFile11 } from "node:fs/promises";
|
|
10787
|
+
import { join as join10 } from "node:path";
|
|
8452
10788
|
|
|
8453
10789
|
// src/verify/typefloor.ts
|
|
8454
10790
|
var TYPE_FLOOR_PX = 40;
|
|
@@ -8502,7 +10838,7 @@ function svgZones(html) {
|
|
|
8502
10838
|
return zones;
|
|
8503
10839
|
}
|
|
8504
10840
|
function userUnit(zones, at) {
|
|
8505
|
-
return zones.filter((
|
|
10841
|
+
return zones.filter((z5) => at >= z5.start && at < z5.end).reduce((unit, z5) => unit * z5.unit, 1);
|
|
8506
10842
|
}
|
|
8507
10843
|
function where(html, at) {
|
|
8508
10844
|
const open = html.lastIndexOf("<", at);
|
|
@@ -8882,21 +11218,21 @@ function captionBottom(sid, selector, fallbackPx) {
|
|
|
8882
11218
|
async function fidelity(dir, opts = {}) {
|
|
8883
11219
|
const started = Date.now();
|
|
8884
11220
|
const floor = opts.floor ?? INK_FLOOR;
|
|
8885
|
-
const notMeasured = (
|
|
11221
|
+
const notMeasured = (why3) => ({
|
|
8886
11222
|
stops: [],
|
|
8887
11223
|
findings: [
|
|
8888
11224
|
{
|
|
8889
11225
|
severity: "warning",
|
|
8890
11226
|
gate: "fidelity",
|
|
8891
11227
|
rule: "not_measured",
|
|
8892
|
-
message: `did not check whether each stop draws anything: ${
|
|
11228
|
+
message: `did not check whether each stop draws anything: ${why3}`
|
|
8893
11229
|
}
|
|
8894
11230
|
],
|
|
8895
11231
|
elapsedMs: Date.now() - started
|
|
8896
11232
|
});
|
|
8897
11233
|
const stops = opts.stops ?? readStops(
|
|
8898
|
-
await
|
|
8899
|
-
await
|
|
11234
|
+
await readFile11(join10(dir, TIMING_FILE), "utf8").catch(() => null),
|
|
11235
|
+
await readFile11(join10(dir, DECK_PAGE), "utf8").catch(() => null)
|
|
8900
11236
|
);
|
|
8901
11237
|
if (stops.length === 0) return notMeasured("the deck declares no stops");
|
|
8902
11238
|
let deck = null;
|
|
@@ -8952,13 +11288,13 @@ async function fidelity(dir, opts = {}) {
|
|
|
8952
11288
|
}
|
|
8953
11289
|
|
|
8954
11290
|
// src/verify/drift.ts
|
|
8955
|
-
import { execFile as
|
|
8956
|
-
import { createHash as
|
|
8957
|
-
import { mkdtemp as mkdtemp3, readdir, readFile as
|
|
11291
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
11292
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
11293
|
+
import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile12, rm as rm7 } from "node:fs/promises";
|
|
8958
11294
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
8959
|
-
import { join as
|
|
8960
|
-
import { promisify as
|
|
8961
|
-
var
|
|
11295
|
+
import { join as join11 } from "node:path";
|
|
11296
|
+
import { promisify as promisify3 } from "node:util";
|
|
11297
|
+
var run3 = promisify3(execFile3);
|
|
8962
11298
|
var FLOOR_DB = 40;
|
|
8963
11299
|
var COMPOSITION_PAGE = "index.html";
|
|
8964
11300
|
var DEFAULT_RENDER_TIMEOUT_MS = 18e5;
|
|
@@ -8995,13 +11331,13 @@ function measureMotion(hashes, scenes, seconds) {
|
|
|
8995
11331
|
async function drift(dir, opts = {}) {
|
|
8996
11332
|
const mode = opts.mode ?? "psnr";
|
|
8997
11333
|
const floorDb = opts.floorDb ?? FLOOR_DB;
|
|
8998
|
-
const work = opts.workDir ?? await mkdtemp3(
|
|
8999
|
-
const a =
|
|
9000
|
-
const b =
|
|
11334
|
+
const work = opts.workDir ?? await mkdtemp3(join11(tmpdir3(), "decksmith-drift-"));
|
|
11335
|
+
const a = join11(work, "a");
|
|
11336
|
+
const b = join11(work, "b");
|
|
9001
11337
|
const report = await compare(dir, a, b, mode, floorDb, opts);
|
|
9002
11338
|
const keep = opts.keep || !report.passed;
|
|
9003
11339
|
if (keep) report.kept = { a, b };
|
|
9004
|
-
else if (!opts.workDir) await
|
|
11340
|
+
else if (!opts.workDir) await rm7(work, { recursive: true, force: true });
|
|
9005
11341
|
return report;
|
|
9006
11342
|
}
|
|
9007
11343
|
async function compare(dir, a, b, mode, floorDb, opts) {
|
|
@@ -9016,11 +11352,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
|
|
|
9016
11352
|
[a, "first"],
|
|
9017
11353
|
[b, "second"]
|
|
9018
11354
|
].entries()) {
|
|
9019
|
-
const
|
|
9020
|
-
if (
|
|
11355
|
+
const why3 = await render(dir, out, { ...opts, workers: counts[i] });
|
|
11356
|
+
if (why3)
|
|
9021
11357
|
return {
|
|
9022
11358
|
passed: false,
|
|
9023
|
-
findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${
|
|
11359
|
+
findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why3}`)],
|
|
9024
11360
|
...empty
|
|
9025
11361
|
};
|
|
9026
11362
|
}
|
|
@@ -9052,11 +11388,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
|
|
|
9052
11388
|
const differing = [];
|
|
9053
11389
|
const ha = [];
|
|
9054
11390
|
for (const [i, name] of fa.entries()) {
|
|
9055
|
-
const one = await sha(
|
|
11391
|
+
const one = await sha(join11(a, name));
|
|
9056
11392
|
ha.push(one);
|
|
9057
|
-
if (one !== await sha(
|
|
11393
|
+
if (one !== await sha(join11(b, name))) differing.push(i + 1);
|
|
9058
11394
|
}
|
|
9059
|
-
const html = await
|
|
11395
|
+
const html = await readFile12(join11(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
|
|
9060
11396
|
const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
|
|
9061
11397
|
let worst;
|
|
9062
11398
|
if (differing.length > 0) {
|
|
@@ -9156,7 +11492,7 @@ async function render(dir, out, opts) {
|
|
|
9156
11492
|
];
|
|
9157
11493
|
if (opts.workers !== void 0) args.push("-w", String(opts.workers));
|
|
9158
11494
|
try {
|
|
9159
|
-
await
|
|
11495
|
+
await run3("npx", args, {
|
|
9160
11496
|
timeout: opts.timeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS,
|
|
9161
11497
|
maxBuffer: 32 << 20
|
|
9162
11498
|
});
|
|
@@ -9167,11 +11503,11 @@ async function render(dir, out, opts) {
|
|
|
9167
11503
|
}
|
|
9168
11504
|
}
|
|
9169
11505
|
async function frames(dir) {
|
|
9170
|
-
const names = await
|
|
11506
|
+
const names = await readdir2(dir).catch(() => []);
|
|
9171
11507
|
return names.filter((n3) => n3.endsWith(".png")).sort();
|
|
9172
11508
|
}
|
|
9173
11509
|
async function sha(file) {
|
|
9174
|
-
return
|
|
11510
|
+
return createHash9("sha256").update(await readFile12(file)).digest("hex");
|
|
9175
11511
|
}
|
|
9176
11512
|
var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
|
|
9177
11513
|
function framePattern(name) {
|
|
@@ -9195,7 +11531,7 @@ async function psnr(a, b, firstFrame) {
|
|
|
9195
11531
|
if (!seq) return { error: `Cannot read a frame-number pattern out of "${firstFrame}".` };
|
|
9196
11532
|
let stdout;
|
|
9197
11533
|
try {
|
|
9198
|
-
({ stdout } = await
|
|
11534
|
+
({ stdout } = await run3(
|
|
9199
11535
|
"ffmpeg",
|
|
9200
11536
|
[
|
|
9201
11537
|
"-hide_banner",
|
|
@@ -9204,11 +11540,11 @@ async function psnr(a, b, firstFrame) {
|
|
|
9204
11540
|
"-start_number",
|
|
9205
11541
|
String(seq.start),
|
|
9206
11542
|
"-i",
|
|
9207
|
-
|
|
11543
|
+
join11(a, seq.pattern),
|
|
9208
11544
|
"-start_number",
|
|
9209
11545
|
String(seq.start),
|
|
9210
11546
|
"-i",
|
|
9211
|
-
|
|
11547
|
+
join11(b, seq.pattern),
|
|
9212
11548
|
"-lavfi",
|
|
9213
11549
|
"psnr=stats_file=-",
|
|
9214
11550
|
"-f",
|
|
@@ -9250,11 +11586,11 @@ function moved(motion) {
|
|
|
9250
11586
|
const rest = motion.unmeasured.length > 0 ? ` ${motion.unmeasured.length} scene(s) had too few frames of their own to judge: ${motion.unmeasured.join(", ")}.` : "";
|
|
9251
11587
|
return ` All ${measured} measurable scene(s) moved within their own window.${rest}`;
|
|
9252
11588
|
}
|
|
9253
|
-
function finding(rule,
|
|
9254
|
-
return { severity: "error", gate: "drift", rule, message };
|
|
11589
|
+
function finding(rule, message2) {
|
|
11590
|
+
return { severity: "error", gate: "drift", rule, message: message2 };
|
|
9255
11591
|
}
|
|
9256
|
-
function note(
|
|
9257
|
-
return { severity: "info", gate: "drift", rule: "stable", message };
|
|
11592
|
+
function note(message2) {
|
|
11593
|
+
return { severity: "info", gate: "drift", rule: "stable", message: message2 };
|
|
9258
11594
|
}
|
|
9259
11595
|
function tail2(s) {
|
|
9260
11596
|
return s.trim().split("\n").slice(-3).join(" / ").slice(0, 400);
|
|
@@ -9262,18 +11598,28 @@ function tail2(s) {
|
|
|
9262
11598
|
|
|
9263
11599
|
// src/verify/index.ts
|
|
9264
11600
|
var NONDETERMINISM = [
|
|
9265
|
-
[/\bMath\.random\s*\(/, "math_random"],
|
|
9266
|
-
[/\bDate\.now\s*\(/, "date_now"],
|
|
9267
|
-
[/\bnew\s+Date\s*\(\s*\)/, "date_now"],
|
|
9268
|
-
[/\bperformance\.now\s*\(/, "performance_now"],
|
|
9269
|
-
[/\bfetch\s*\(/, "runtime_fetch"],
|
|
9270
|
-
[/\bXMLHttpRequest\b/, "runtime_fetch"]
|
|
11601
|
+
[/\bMath\.random\s*\(/, "math_random", "calls"],
|
|
11602
|
+
[/\bDate\.now\s*\(/, "date_now", "calls"],
|
|
11603
|
+
[/\bnew\s+Date\s*\(\s*\)/, "date_now", "calls"],
|
|
11604
|
+
[/\bperformance\.now\s*\(/, "performance_now", "calls"],
|
|
11605
|
+
[/\bfetch\s*\(/, "runtime_fetch", "calls"],
|
|
11606
|
+
[/\bXMLHttpRequest\b/, "runtime_fetch", "calls"],
|
|
11607
|
+
// The TAG, matched with a word boundary so `<iframes>` and the word "iframe"
|
|
11608
|
+
// in a comment or a `createElement("iframe")` in the wrapper's own runtime are
|
|
11609
|
+
// not it. `deck.html` is never scanned at all (`readCompositions`), which is
|
|
11610
|
+
// what keeps the player's own frame out of this.
|
|
11611
|
+
[
|
|
11612
|
+
/<iframe\b/i,
|
|
11613
|
+
"third_party_iframe",
|
|
11614
|
+
"embeds",
|
|
11615
|
+
"Bake what the frame was showing into the deck instead: a still as a figure, or a clip claim-figure can play."
|
|
11616
|
+
]
|
|
9271
11617
|
];
|
|
9272
11618
|
async function verify(dir, opts = {}, storyboard, kept, source) {
|
|
9273
11619
|
const html = await readCompositions(dir);
|
|
9274
11620
|
const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
|
|
9275
11621
|
const narration = scanNarration(
|
|
9276
|
-
await
|
|
11622
|
+
await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => ""),
|
|
9277
11623
|
await listFiles(dir)
|
|
9278
11624
|
);
|
|
9279
11625
|
const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
|
|
@@ -9310,8 +11656,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
|
|
|
9310
11656
|
}
|
|
9311
11657
|
async function declaredStops(dir) {
|
|
9312
11658
|
return readStops(
|
|
9313
|
-
await
|
|
9314
|
-
await
|
|
11659
|
+
await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => null),
|
|
11660
|
+
await readFile13(join12(dir, DECK_PAGE), "utf8").catch(() => null)
|
|
9315
11661
|
);
|
|
9316
11662
|
}
|
|
9317
11663
|
var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
|
|
@@ -9334,8 +11680,8 @@ function scanNarration(page, files) {
|
|
|
9334
11680
|
const dir = typeof parsed.dir === "string" && parsed.dir ? `${parsed.dir}/` : "";
|
|
9335
11681
|
const scenes = parsed.scenes ?? {};
|
|
9336
11682
|
const missing = /* @__PURE__ */ new Set();
|
|
9337
|
-
for (const
|
|
9338
|
-
for (const s of
|
|
11683
|
+
for (const segments2 of Object.values(scenes)) {
|
|
11684
|
+
for (const s of segments2 ?? []) {
|
|
9339
11685
|
if (typeof s?.audio !== "string" || /^[a-z][a-z0-9+.-]*:|^\//i.test(s.audio)) continue;
|
|
9340
11686
|
if (!files.has(`${dir}${s.audio}`)) missing.add(s.audio);
|
|
9341
11687
|
}
|
|
@@ -9351,7 +11697,7 @@ function scanNarration(page, files) {
|
|
|
9351
11697
|
];
|
|
9352
11698
|
}
|
|
9353
11699
|
async function readTiming(dir) {
|
|
9354
|
-
const raw2 = await
|
|
11700
|
+
const raw2 = await readFile13(join12(dir, TIMING_FILE), "utf8").catch(() => "");
|
|
9355
11701
|
if (!raw2) return void 0;
|
|
9356
11702
|
try {
|
|
9357
11703
|
const parsed = JSON.parse(raw2);
|
|
@@ -9361,9 +11707,9 @@ async function readTiming(dir) {
|
|
|
9361
11707
|
}
|
|
9362
11708
|
}
|
|
9363
11709
|
async function listFiles(dir) {
|
|
9364
|
-
const entries = await
|
|
11710
|
+
const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
9365
11711
|
return new Set(
|
|
9366
|
-
entries.filter((e) => e.isFile()).map((e) => relative(dir,
|
|
11712
|
+
entries.filter((e) => e.isFile()).map((e) => relative(dir, join12(e.parentPath, e.name)).split(sep).join("/"))
|
|
9367
11713
|
);
|
|
9368
11714
|
}
|
|
9369
11715
|
var INSTEAD = {
|
|
@@ -9489,20 +11835,20 @@ function scanBeatCount(storyboard, prefs) {
|
|
|
9489
11835
|
];
|
|
9490
11836
|
}
|
|
9491
11837
|
function scanPaperArc(storyboard, prefs) {
|
|
9492
|
-
return arcProblems(storyboard, prefs).map((
|
|
11838
|
+
return arcProblems(storyboard, prefs).map((message2) => ({
|
|
9493
11839
|
severity: "warning",
|
|
9494
11840
|
gate: "storyboard",
|
|
9495
11841
|
rule: "paper_arc",
|
|
9496
|
-
message
|
|
11842
|
+
message: message2
|
|
9497
11843
|
}));
|
|
9498
11844
|
}
|
|
9499
11845
|
function scanNarrationDrift(storyboard, narration) {
|
|
9500
11846
|
const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
9501
11847
|
const stale = [];
|
|
9502
11848
|
for (const beat of storyboard.beats) {
|
|
9503
|
-
const
|
|
9504
|
-
if (!
|
|
9505
|
-
if (flat(
|
|
11849
|
+
const segments2 = narration.beats[beat.id];
|
|
11850
|
+
if (!segments2?.length || !flat(beat.narration)) continue;
|
|
11851
|
+
if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
|
|
9506
11852
|
}
|
|
9507
11853
|
if (!stale.length) return [];
|
|
9508
11854
|
return [
|
|
@@ -9547,8 +11893,8 @@ function scanNarrationLead(beats, timing) {
|
|
|
9547
11893
|
}
|
|
9548
11894
|
return findings;
|
|
9549
11895
|
}
|
|
9550
|
-
function firstMention(
|
|
9551
|
-
for (const segment of
|
|
11896
|
+
function firstMention(segments2, label) {
|
|
11897
|
+
for (const segment of segments2) {
|
|
9552
11898
|
for (const cue of segment.cues) {
|
|
9553
11899
|
const words2 = cue.text.split(/\s+/);
|
|
9554
11900
|
let at = 0;
|
|
@@ -9606,35 +11952,35 @@ var STOP = /* @__PURE__ */ new Set([
|
|
|
9606
11952
|
function scanDeterminism(html, file) {
|
|
9607
11953
|
const findings = [];
|
|
9608
11954
|
const lines = html.split("\n");
|
|
9609
|
-
for (const [pattern, rule] of NONDETERMINISM) {
|
|
11955
|
+
for (const [pattern, rule, verb, remedy2] of NONDETERMINISM) {
|
|
9610
11956
|
const i = lines.findIndex((line2) => pattern.test(line2));
|
|
9611
11957
|
if (i < 0) continue;
|
|
9612
11958
|
findings.push({
|
|
9613
11959
|
severity: "error",
|
|
9614
11960
|
gate: "determinism",
|
|
9615
11961
|
rule,
|
|
9616
|
-
message: `${file}:${i + 1}
|
|
11962
|
+
message: `${file}:${i + 1} ${verb} \`${lines[i]?.match(pattern)?.[0]}\` at render time, so two renders of this deck will not be identical.` + (remedy2 ? ` ${remedy2}` : "")
|
|
9617
11963
|
});
|
|
9618
11964
|
}
|
|
9619
11965
|
return findings;
|
|
9620
11966
|
}
|
|
9621
11967
|
async function readCompositions(dir) {
|
|
9622
|
-
const entries = await
|
|
11968
|
+
const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
9623
11969
|
const files = entries.filter(
|
|
9624
11970
|
(e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
|
|
9625
|
-
).map((e) =>
|
|
11971
|
+
).map((e) => join12(e.parentPath, e.name));
|
|
9626
11972
|
return Promise.all(
|
|
9627
|
-
files.map(async (f) => [relative(dir, f), await
|
|
11973
|
+
files.map(async (f) => [relative(dir, f), await readFile13(f, "utf8")])
|
|
9628
11974
|
);
|
|
9629
11975
|
}
|
|
9630
11976
|
|
|
9631
11977
|
// src/render/render.ts
|
|
9632
|
-
import { mkdir as
|
|
9633
|
-
import { basename, dirname, join as
|
|
11978
|
+
import { mkdir as mkdir8, readFile as readFile14, rename as rename2, rm as rm8, writeFile as writeFile10 } from "node:fs/promises";
|
|
11979
|
+
import { basename as basename3, dirname, join as join14, resolve as resolve3 } from "node:path";
|
|
9634
11980
|
|
|
9635
11981
|
// src/render/captions.ts
|
|
9636
|
-
import { mkdir as
|
|
9637
|
-
import { join as
|
|
11982
|
+
import { mkdir as mkdir7, writeFile as writeFile9 } from "node:fs/promises";
|
|
11983
|
+
import { join as join13 } from "node:path";
|
|
9638
11984
|
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
9639
11985
|
var DECK_FONT_CSS = "assets/fonts/fonts.css";
|
|
9640
11986
|
function cssString(value) {
|
|
@@ -9690,7 +12036,7 @@ ${bands}
|
|
|
9690
12036
|
</div>
|
|
9691
12037
|
`;
|
|
9692
12038
|
}
|
|
9693
|
-
function
|
|
12039
|
+
function measure3(count) {
|
|
9694
12040
|
const doc = document;
|
|
9695
12041
|
const probe2 = doc.getElementById("c0");
|
|
9696
12042
|
if (probe2) {
|
|
@@ -9710,255 +12056,89 @@ function measure2(count) {
|
|
|
9710
12056
|
const r = el.querySelector("span").getBoundingClientRect();
|
|
9711
12057
|
el.classList.remove("on");
|
|
9712
12058
|
rects.push({ x: r.x, y: r.y, w: r.width, h: r.height });
|
|
9713
|
-
}
|
|
9714
|
-
return rects;
|
|
9715
|
-
}
|
|
9716
|
-
var CAPTION_NEED = "draw the captions with (`render` needs one for the capture too)";
|
|
9717
|
-
async function captionBlocker() {
|
|
9718
|
-
try {
|
|
9719
|
-
await import("puppeteer-core");
|
|
9720
|
-
await chromePath(CAPTION_NEED);
|
|
9721
|
-
return null;
|
|
9722
|
-
} catch (err) {
|
|
9723
|
-
return err instanceof Error ? err.message : String(err);
|
|
9724
|
-
}
|
|
9725
|
-
}
|
|
9726
|
-
async function renderCaptions(cues, style, deck, work) {
|
|
9727
|
-
if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
|
|
9728
|
-
const fontCss = join12(deck, DECK_FONT_CSS);
|
|
9729
|
-
const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
|
|
9730
|
-
const page = join12(work, "captions.html");
|
|
9731
|
-
await mkdir6(work, { recursive: true });
|
|
9732
|
-
await writeFile8(page, captionPage(cues, style, href));
|
|
9733
|
-
const { default: puppeteer } = await import("puppeteer-core");
|
|
9734
|
-
const browser = await puppeteer.launch({
|
|
9735
|
-
executablePath: await chromePath(CAPTION_NEED),
|
|
9736
|
-
headless: true,
|
|
9737
|
-
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
9738
|
-
});
|
|
9739
|
-
try {
|
|
9740
|
-
const tab = await browser.newPage();
|
|
9741
|
-
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
9742
|
-
await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
|
|
9743
|
-
await tab.evaluate(() => document.fonts.ready);
|
|
9744
|
-
const rects = await tab.evaluate(measure2, cues.length);
|
|
9745
|
-
const box = union(rects);
|
|
9746
|
-
const files = [];
|
|
9747
|
-
for (const [i, cue] of cues.entries()) {
|
|
9748
|
-
void cue;
|
|
9749
|
-
const name = `cap${String(i).padStart(4, "0")}.png`;
|
|
9750
|
-
await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
|
|
9751
|
-
await tab.screenshot({
|
|
9752
|
-
path: join12(work, name),
|
|
9753
|
-
type: "png",
|
|
9754
|
-
omitBackground: true,
|
|
9755
|
-
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
9756
|
-
});
|
|
9757
|
-
await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
|
|
9758
|
-
files.push(name);
|
|
9759
|
-
}
|
|
9760
|
-
return { files, ...box };
|
|
9761
|
-
} finally {
|
|
9762
|
-
await browser.close();
|
|
9763
|
-
}
|
|
9764
|
-
}
|
|
9765
|
-
function union(rects) {
|
|
9766
|
-
const left = Math.floor(Math.min(...rects.map((r) => r.x)));
|
|
9767
|
-
const top = Math.floor(Math.min(...rects.map((r) => r.y)));
|
|
9768
|
-
const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
|
|
9769
|
-
const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
|
|
9770
|
-
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
9771
|
-
}
|
|
9772
|
-
var t3 = (seconds) => seconds.toFixed(3);
|
|
9773
|
-
function overlayGraph(cues, band, input = "0:v", output = "vout") {
|
|
9774
|
-
return cues.map((cue, i) => {
|
|
9775
|
-
const from = i === 0 ? `[${input}]` : `[v${i}]`;
|
|
9776
|
-
const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
|
|
9777
|
-
const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
|
|
9778
|
-
return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
|
|
9779
|
-
}).join(";\n");
|
|
9780
|
-
}
|
|
9781
|
-
function overlayInputs(band) {
|
|
9782
|
-
return band.files.flatMap((file) => ["-i", file]);
|
|
9783
|
-
}
|
|
9784
|
-
|
|
9785
|
-
// src/render/ffmpeg.ts
|
|
9786
|
-
import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
|
|
9787
|
-
import { promisify as promisify3 } from "node:util";
|
|
9788
|
-
var run3 = promisify3(execFile3);
|
|
9789
|
-
var DEFAULT_TIMEOUT_MS3 = 36e5;
|
|
9790
|
-
async function runTool(file, args, opts = {}) {
|
|
9791
|
-
try {
|
|
9792
|
-
return await run3(file, args, {
|
|
9793
|
-
cwd: opts.cwd,
|
|
9794
|
-
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS3,
|
|
9795
|
-
maxBuffer: 64 << 20
|
|
9796
|
-
});
|
|
9797
|
-
} catch (err) {
|
|
9798
|
-
const e = err;
|
|
9799
|
-
if (e.code === "ENOENT") {
|
|
9800
|
-
throw new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`);
|
|
9801
|
-
}
|
|
9802
|
-
const tail3 = (e.stderr ?? e.stdout ?? e.message).trim().split("\n").slice(-8).join("\n");
|
|
9803
|
-
throw new Error(`${file} failed:
|
|
9804
|
-
${tail3}`);
|
|
9805
|
-
}
|
|
9806
|
-
}
|
|
9807
|
-
function runLive(file, args) {
|
|
9808
|
-
return new Promise((resolve5, reject) => {
|
|
9809
|
-
const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
9810
|
-
child.on("error", (err) => {
|
|
9811
|
-
reject(
|
|
9812
|
-
err.code === "ENOENT" ? new Error(`${file} is not installed, or not on PATH. \`render\` needs it.`) : err
|
|
9813
|
-
);
|
|
9814
|
-
});
|
|
9815
|
-
child.on("close", (code, signal) => {
|
|
9816
|
-
if (code === 0) resolve5();
|
|
9817
|
-
else if (signal) {
|
|
9818
|
-
reject(
|
|
9819
|
-
new Error(
|
|
9820
|
-
`${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.`
|
|
9821
|
-
)
|
|
9822
|
-
);
|
|
9823
|
-
} else reject(new Error(`${file} exited ${code}.`));
|
|
9824
|
-
});
|
|
9825
|
-
});
|
|
9826
|
-
}
|
|
9827
|
-
async function probe(path2) {
|
|
9828
|
-
const { stdout } = await runTool("ffprobe", [
|
|
9829
|
-
"-v",
|
|
9830
|
-
"error",
|
|
9831
|
-
"-show_entries",
|
|
9832
|
-
"stream=codec_type,width,height,r_frame_rate,nb_frames:format=duration",
|
|
9833
|
-
"-of",
|
|
9834
|
-
"json",
|
|
9835
|
-
path2
|
|
9836
|
-
]);
|
|
9837
|
-
const json = JSON.parse(stdout);
|
|
9838
|
-
const streams = json.streams ?? [];
|
|
9839
|
-
const video = streams.find((s) => s.codec_type === "video");
|
|
9840
|
-
if (!video) throw new Error(`${path2} has no video stream.`);
|
|
9841
|
-
const [num, den] = (video.r_frame_rate ?? "30/1").split("/");
|
|
9842
|
-
const fps = Number(num) / (Number(den) || 1);
|
|
9843
|
-
const seconds = Number(json.format?.duration ?? 0);
|
|
9844
|
-
const frames2 = Number(video.nb_frames ?? 0) || Math.round(seconds * fps);
|
|
9845
|
-
return {
|
|
9846
|
-
width: video.width ?? 0,
|
|
9847
|
-
height: video.height ?? 0,
|
|
9848
|
-
fps,
|
|
9849
|
-
frames: frames2,
|
|
9850
|
-
seconds,
|
|
9851
|
-
hasAudio: streams.some((s) => s.codec_type === "audio")
|
|
9852
|
-
};
|
|
9853
|
-
}
|
|
9854
|
-
function encoderArgs(fps) {
|
|
9855
|
-
return [
|
|
9856
|
-
"-c:v",
|
|
9857
|
-
"libx264",
|
|
9858
|
-
"-preset",
|
|
9859
|
-
"veryfast",
|
|
9860
|
-
"-crf",
|
|
9861
|
-
"16",
|
|
9862
|
-
"-pix_fmt",
|
|
9863
|
-
"yuv420p",
|
|
9864
|
-
"-g",
|
|
9865
|
-
"12",
|
|
9866
|
-
"-r",
|
|
9867
|
-
String(fps),
|
|
9868
|
-
"-fps_mode",
|
|
9869
|
-
"cfr"
|
|
9870
|
-
];
|
|
9871
|
-
}
|
|
9872
|
-
function pieceFilter(motion, freeze) {
|
|
9873
|
-
const chain = [`trim=end_frame=${motion}`, "setpts=N/FRAME_RATE/TB"];
|
|
9874
|
-
if (freeze > 0) chain.push(`tpad=stop_mode=clone:stop=${freeze}`);
|
|
9875
|
-
return chain.join(",");
|
|
9876
|
-
}
|
|
9877
|
-
function pieceArgs(source, fromFrame, motion, freeze, fps, out) {
|
|
9878
|
-
return [
|
|
9879
|
-
"-y",
|
|
9880
|
-
"-hide_banner",
|
|
9881
|
-
"-loglevel",
|
|
9882
|
-
"error",
|
|
9883
|
-
// Half a frame in, so a time that is exactly on a boundary cannot round to
|
|
9884
|
-
// the frame before it.
|
|
9885
|
-
"-ss",
|
|
9886
|
-
((fromFrame + 0.5) / fps).toFixed(6),
|
|
9887
|
-
"-i",
|
|
9888
|
-
source,
|
|
9889
|
-
"-an",
|
|
9890
|
-
"-vf",
|
|
9891
|
-
pieceFilter(motion, freeze),
|
|
9892
|
-
"-frames:v",
|
|
9893
|
-
String(motion + freeze),
|
|
9894
|
-
...encoderArgs(fps),
|
|
9895
|
-
"-f",
|
|
9896
|
-
"mpegts",
|
|
9897
|
-
out
|
|
9898
|
-
];
|
|
12059
|
+
}
|
|
12060
|
+
return rects;
|
|
9899
12061
|
}
|
|
9900
|
-
var
|
|
9901
|
-
function
|
|
9902
|
-
|
|
9903
|
-
(
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
return lines.join(";\n");
|
|
12062
|
+
var CAPTION_NEED = "draw the captions with (`render` needs one for the capture too)";
|
|
12063
|
+
async function captionBlocker() {
|
|
12064
|
+
try {
|
|
12065
|
+
await import("puppeteer-core");
|
|
12066
|
+
await chromePath(CAPTION_NEED);
|
|
12067
|
+
return null;
|
|
12068
|
+
} catch (err) {
|
|
12069
|
+
return err instanceof Error ? err.message : String(err);
|
|
12070
|
+
}
|
|
9910
12071
|
}
|
|
9911
|
-
function
|
|
9912
|
-
|
|
9913
|
-
const
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
12072
|
+
async function renderCaptions(cues, style, deck, work) {
|
|
12073
|
+
if (cues.length === 0) throw new Error("renderCaptions was given no cues.");
|
|
12074
|
+
const fontCss = join13(deck, DECK_FONT_CSS);
|
|
12075
|
+
const href = await import("node:fs/promises").then((fs) => fs.stat(fontCss).catch(() => null)) ? pathToFileURL2(fontCss).href : null;
|
|
12076
|
+
const page = join13(work, "captions.html");
|
|
12077
|
+
await mkdir7(work, { recursive: true });
|
|
12078
|
+
await writeFile9(page, captionPage(cues, style, href));
|
|
12079
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
12080
|
+
const browser = await puppeteer.launch({
|
|
12081
|
+
executablePath: await chromePath(CAPTION_NEED),
|
|
12082
|
+
headless: true,
|
|
12083
|
+
args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
|
|
12084
|
+
});
|
|
12085
|
+
try {
|
|
12086
|
+
const tab = await browser.newPage();
|
|
12087
|
+
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
12088
|
+
await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
|
|
12089
|
+
await tab.evaluate(() => document.fonts.ready);
|
|
12090
|
+
const rects = await tab.evaluate(measure3, cues.length);
|
|
12091
|
+
const box = union(rects);
|
|
12092
|
+
const files = [];
|
|
12093
|
+
for (const [i, cue] of cues.entries()) {
|
|
12094
|
+
void cue;
|
|
12095
|
+
const name = `cap${String(i).padStart(4, "0")}.png`;
|
|
12096
|
+
await tab.evaluate((id2) => document.getElementById(id2)?.classList.add("on"), `c${i}`);
|
|
12097
|
+
await tab.screenshot({
|
|
12098
|
+
path: join13(work, name),
|
|
12099
|
+
type: "png",
|
|
12100
|
+
omitBackground: true,
|
|
12101
|
+
clip: { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
12102
|
+
});
|
|
12103
|
+
await tab.evaluate((id2) => document.getElementById(id2)?.classList.remove("on"), `c${i}`);
|
|
12104
|
+
files.push(name);
|
|
12105
|
+
}
|
|
12106
|
+
return { files, ...box };
|
|
12107
|
+
} finally {
|
|
12108
|
+
await browser.close();
|
|
12109
|
+
}
|
|
9929
12110
|
}
|
|
9930
|
-
function
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
|
|
9944
|
-
|
|
9945
|
-
|
|
9946
|
-
|
|
9947
|
-
|
|
9948
|
-
};
|
|
12111
|
+
function union(rects) {
|
|
12112
|
+
const left = Math.floor(Math.min(...rects.map((r) => r.x)));
|
|
12113
|
+
const top = Math.floor(Math.min(...rects.map((r) => r.y)));
|
|
12114
|
+
const right = Math.ceil(Math.max(...rects.map((r) => r.x + r.w)));
|
|
12115
|
+
const bottom = Math.ceil(Math.max(...rects.map((r) => r.y + r.h)));
|
|
12116
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
12117
|
+
}
|
|
12118
|
+
var t3 = (seconds) => seconds.toFixed(3);
|
|
12119
|
+
function overlayGraph(cues, band, input = "0:v", output = "vout") {
|
|
12120
|
+
return cues.map((cue, i) => {
|
|
12121
|
+
const from = i === 0 ? `[${input}]` : `[v${i}]`;
|
|
12122
|
+
const to = i === cues.length - 1 ? `[${output}]` : `[v${i + 1}]`;
|
|
12123
|
+
const enable = `enable='gte(t,${t3(cue.start)})*lt(t,${t3(cue.end)})'`;
|
|
12124
|
+
return `${from}[${i + 1}:v]overlay=x=${band.x}:y=${band.y}:format=yuv444:${enable}${to}`;
|
|
12125
|
+
}).join(";\n");
|
|
12126
|
+
}
|
|
12127
|
+
function overlayInputs(band) {
|
|
12128
|
+
return band.files.flatMap((file) => ["-i", file]);
|
|
9949
12129
|
}
|
|
9950
12130
|
|
|
9951
12131
|
// src/render/render.ts
|
|
9952
|
-
var SRT_NAME = (out) => `${
|
|
12132
|
+
var SRT_NAME = (out) => `${basename3(out).replace(/\.[^.]+$/, "")}.srt`;
|
|
9953
12133
|
function subtitlePlan(mode) {
|
|
9954
12134
|
return { sidecar: mode !== "none", burn: mode === "burn" };
|
|
9955
12135
|
}
|
|
9956
12136
|
async function render2(opts) {
|
|
9957
12137
|
const log = opts.log ?? (() => {
|
|
9958
12138
|
});
|
|
9959
|
-
const deck =
|
|
9960
|
-
const out =
|
|
9961
|
-
const work =
|
|
12139
|
+
const deck = resolve3(opts.deck);
|
|
12140
|
+
const out = resolve3(opts.out);
|
|
12141
|
+
const work = join14(dirname(out), `.${basename3(out)}.parts`);
|
|
9962
12142
|
const timing = await readTiming2(deck);
|
|
9963
12143
|
if (opts.targetSeconds && !opts.allowFastPlayback) {
|
|
9964
12144
|
const refusal = playbackRefusal(
|
|
@@ -9974,9 +12154,9 @@ async function render2(opts) {
|
|
|
9974
12154
|
if (blocker) throw new Error(`Cannot burn in captions: ${blocker}`);
|
|
9975
12155
|
}
|
|
9976
12156
|
const burnable = plan0.burn;
|
|
9977
|
-
await
|
|
12157
|
+
await mkdir8(work, { recursive: true });
|
|
9978
12158
|
try {
|
|
9979
|
-
const raw2 = opts.video ?
|
|
12159
|
+
const raw2 = opts.video ? resolve3(opts.video) : await capture(deck, join14(work, "raw.mp4"), opts, log);
|
|
9980
12160
|
const shot = await probe(raw2);
|
|
9981
12161
|
log(
|
|
9982
12162
|
`render: ${shot.frames} frames, ${shot.width}\xD7${shot.height}, ${shot.fps.toFixed(3)} fps, ${shot.seconds.toFixed(2)}s`
|
|
@@ -9989,18 +12169,18 @@ async function render2(opts) {
|
|
|
9989
12169
|
);
|
|
9990
12170
|
}
|
|
9991
12171
|
const retimed = await retime(raw2, plan, work, log);
|
|
9992
|
-
const srtPath =
|
|
12172
|
+
const srtPath = join14(dirname(out), SRT_NAME(out));
|
|
9993
12173
|
const playback = opts.targetSeconds ? playbackFactor(plan.frames / shot.fps, opts.targetSeconds) : 1;
|
|
9994
12174
|
const cues = playback > 1 ? plan.cues.map((c) => scaleCue(c, playback)) : plan.cues;
|
|
9995
12175
|
const srt = plan0.sidecar ? toSrt(cues) : "";
|
|
9996
|
-
if (srt) await
|
|
12176
|
+
if (srt) await writeFile10(srtPath, srt);
|
|
9997
12177
|
const burn = burnable && srt.length > 0;
|
|
9998
12178
|
await mux(retimed, timing, plan, deck, out, work, burn ? plan.cues : void 0, shot.fps, log);
|
|
9999
12179
|
if (playback > 1) {
|
|
10000
12180
|
log(`render: speeding playback ${playback}\xD7 to reach ${opts.targetSeconds}s`);
|
|
10001
12181
|
const warning = playbackWarning(playback, p95CueRate(plan.cues));
|
|
10002
12182
|
if (warning) log(`render: ${warning}`);
|
|
10003
|
-
const fast =
|
|
12183
|
+
const fast = join14(work, `fast.${basename3(out)}`);
|
|
10004
12184
|
const muxed = await probe(out);
|
|
10005
12185
|
await runTool(
|
|
10006
12186
|
"ffmpeg",
|
|
@@ -10026,12 +12206,12 @@ async function render2(opts) {
|
|
|
10026
12206
|
captionCps: p95CueRate(cues)
|
|
10027
12207
|
};
|
|
10028
12208
|
} finally {
|
|
10029
|
-
if (!opts.keep) await
|
|
12209
|
+
if (!opts.keep) await rm8(work, { recursive: true, force: true });
|
|
10030
12210
|
}
|
|
10031
12211
|
}
|
|
10032
12212
|
async function readTiming2(deck) {
|
|
10033
|
-
const path2 =
|
|
10034
|
-
const text2 = await
|
|
12213
|
+
const path2 = join14(deck, TIMING_FILE);
|
|
12214
|
+
const text2 = await readFile14(path2, "utf8").catch(() => {
|
|
10035
12215
|
throw new Error(
|
|
10036
12216
|
`${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
|
|
10037
12217
|
);
|
|
@@ -10070,20 +12250,20 @@ async function retime(raw2, plan, work, log) {
|
|
|
10070
12250
|
if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
|
|
10071
12251
|
const list = [];
|
|
10072
12252
|
for (const [i, piece] of plan.pieces.entries()) {
|
|
10073
|
-
const file =
|
|
12253
|
+
const file = join14(work, `p${String(i).padStart(4, "0")}.ts`);
|
|
10074
12254
|
await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
|
|
10075
12255
|
list.push(file);
|
|
10076
12256
|
if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
|
|
10077
12257
|
log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
|
|
10078
12258
|
}
|
|
10079
12259
|
}
|
|
10080
|
-
const listFile =
|
|
10081
|
-
await
|
|
12260
|
+
const listFile = join14(work, "pieces.txt");
|
|
12261
|
+
await writeFile10(
|
|
10082
12262
|
listFile,
|
|
10083
12263
|
`${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
|
|
10084
12264
|
`
|
|
10085
12265
|
);
|
|
10086
|
-
const joined =
|
|
12266
|
+
const joined = join14(work, "retimed.mp4");
|
|
10087
12267
|
await runTool("ffmpeg", [
|
|
10088
12268
|
"-y",
|
|
10089
12269
|
"-hide_banner",
|
|
@@ -10105,7 +12285,7 @@ async function retime(raw2, plan, work, log) {
|
|
|
10105
12285
|
}
|
|
10106
12286
|
async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
|
|
10107
12287
|
const inputs = plan.audio.map((a) => ({
|
|
10108
|
-
file:
|
|
12288
|
+
file: join14(deck, timing.audioDir, a.audio),
|
|
10109
12289
|
delayMs: a.delayMs
|
|
10110
12290
|
}));
|
|
10111
12291
|
const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
|
|
@@ -10126,8 +12306,8 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
|
|
|
10126
12306
|
graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
|
|
10127
12307
|
}
|
|
10128
12308
|
if (graph.length > 0) {
|
|
10129
|
-
const script =
|
|
10130
|
-
await
|
|
12309
|
+
const script = join14(work, "mux.filter");
|
|
12310
|
+
await writeFile10(script, `${graph.join(";\n")}
|
|
10131
12311
|
`);
|
|
10132
12312
|
args.push("-filter_complex_script", script);
|
|
10133
12313
|
}
|
|
@@ -10142,13 +12322,13 @@ async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
|
|
|
10142
12322
|
log(
|
|
10143
12323
|
`render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
|
|
10144
12324
|
);
|
|
10145
|
-
await
|
|
12325
|
+
await mkdir8(dirname(out), { recursive: true });
|
|
10146
12326
|
await runTool("ffmpeg", args, { cwd: work });
|
|
10147
12327
|
}
|
|
10148
12328
|
|
|
10149
12329
|
// src/pack/pack.ts
|
|
10150
|
-
import { mkdir as
|
|
10151
|
-
import { dirname as dirname2, extname as
|
|
12330
|
+
import { mkdir as mkdir9, readFile as readFile15, writeFile as writeFile11 } from "node:fs/promises";
|
|
12331
|
+
import { dirname as dirname2, extname as extname3 } from "node:path";
|
|
10152
12332
|
import { unzipSync, zipSync } from "fflate";
|
|
10153
12333
|
var MTIME = Date.UTC(1980, 0, 2, 12);
|
|
10154
12334
|
var STORED = /* @__PURE__ */ new Set([
|
|
@@ -10184,15 +12364,15 @@ async function writePack(pack2, files, out) {
|
|
|
10184
12364
|
if (!bytes) continue;
|
|
10185
12365
|
if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
|
|
10186
12366
|
check2(path2);
|
|
10187
|
-
entries[path2] = STORED.has(
|
|
12367
|
+
entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
|
|
10188
12368
|
}
|
|
10189
12369
|
const zip = zipSync(entries, { mtime: MTIME });
|
|
10190
|
-
await
|
|
10191
|
-
await
|
|
12370
|
+
await mkdir9(dirname2(out), { recursive: true });
|
|
12371
|
+
await writeFile11(out, zip);
|
|
10192
12372
|
return zip.length;
|
|
10193
12373
|
}
|
|
10194
12374
|
async function readPack(path2) {
|
|
10195
|
-
return openPack(new Uint8Array(await
|
|
12375
|
+
return openPack(new Uint8Array(await readFile15(path2)), path2);
|
|
10196
12376
|
}
|
|
10197
12377
|
function openPack(bytes, label = "pack") {
|
|
10198
12378
|
if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
|
|
@@ -10218,7 +12398,7 @@ function openPack(bytes, label = "pack") {
|
|
|
10218
12398
|
);
|
|
10219
12399
|
const parsed = packSchema.safeParse(json);
|
|
10220
12400
|
if (!parsed.success)
|
|
10221
|
-
throw new Error(`${label}: deck.json is not a valid pack \u2014 ${
|
|
12401
|
+
throw new Error(`${label}: deck.json is not a valid pack \u2014 ${why2(parsed.error)}`);
|
|
10222
12402
|
const files = {};
|
|
10223
12403
|
for (const [name, content] of Object.entries(raw2)) {
|
|
10224
12404
|
if (name === "deck.json" || name.endsWith("/")) continue;
|
|
@@ -10231,164 +12411,13 @@ function check2(label, path2 = label) {
|
|
|
10231
12411
|
if (path2.startsWith("/") || /^[a-z]:/i.test(path2) || path2.split("/").includes(".."))
|
|
10232
12412
|
throw new Error(`${label}: unsafe path in pack`);
|
|
10233
12413
|
}
|
|
10234
|
-
function
|
|
12414
|
+
function why2(error) {
|
|
10235
12415
|
return error.issues.slice(0, 3).map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ");
|
|
10236
12416
|
}
|
|
10237
12417
|
|
|
10238
|
-
// src/pack/media.ts
|
|
10239
|
-
import { createHash as createHash8 } from "node:crypto";
|
|
10240
|
-
import { readFile as readFile14 } from "node:fs/promises";
|
|
10241
|
-
import { extname as extname3 } from "node:path";
|
|
10242
|
-
var PLAYERS = [
|
|
10243
|
-
"youtube.com",
|
|
10244
|
-
"youtube-nocookie.com",
|
|
10245
|
-
"youtu.be",
|
|
10246
|
-
"vimeo.com",
|
|
10247
|
-
"dailymotion.com",
|
|
10248
|
-
"dai.ly",
|
|
10249
|
-
"twitch.tv",
|
|
10250
|
-
"loom.com",
|
|
10251
|
-
"wistia.com",
|
|
10252
|
-
"wistia.net",
|
|
10253
|
-
"streamable.com",
|
|
10254
|
-
"bilibili.com",
|
|
10255
|
-
"soundcloud.com",
|
|
10256
|
-
"tiktok.com"
|
|
10257
|
-
];
|
|
10258
|
-
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
10259
|
-
".png",
|
|
10260
|
-
".jpg",
|
|
10261
|
-
".jpeg",
|
|
10262
|
-
".gif",
|
|
10263
|
-
".webp",
|
|
10264
|
-
".avif",
|
|
10265
|
-
".svg",
|
|
10266
|
-
".mp4",
|
|
10267
|
-
".webm",
|
|
10268
|
-
".mov",
|
|
10269
|
-
".m4v",
|
|
10270
|
-
".mp3",
|
|
10271
|
-
".m4a",
|
|
10272
|
-
".wav",
|
|
10273
|
-
".ogg",
|
|
10274
|
-
".opus",
|
|
10275
|
-
".pdf"
|
|
10276
|
-
]);
|
|
10277
|
-
function isEmbed(url) {
|
|
10278
|
-
const host = hostOf(url);
|
|
10279
|
-
return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
|
|
10280
|
-
}
|
|
10281
|
-
function policyFor(url, prefer) {
|
|
10282
|
-
if (isEmbed(url)) return "embed";
|
|
10283
|
-
const host = hostOf(url);
|
|
10284
|
-
if (host === null || /^data:/i.test(url)) return "bake";
|
|
10285
|
-
if (prefer === "link") return "link";
|
|
10286
|
-
return FILE_EXT.has(extOf(url)) ? "bake" : "link";
|
|
10287
|
-
}
|
|
10288
|
-
async function planMedia(assets, fetcher = fetchAsset) {
|
|
10289
|
-
const plan = {
|
|
10290
|
-
media: [],
|
|
10291
|
-
files: {},
|
|
10292
|
-
bakedCount: 0,
|
|
10293
|
-
bakedBytes: 0,
|
|
10294
|
-
demoted: [],
|
|
10295
|
-
promoted: []
|
|
10296
|
-
};
|
|
10297
|
-
for (const asset of assets) {
|
|
10298
|
-
const policy = policyFor(asset.url, asset.prefer);
|
|
10299
|
-
if (policy !== "bake") {
|
|
10300
|
-
if (asset.prefer === "bake") plan.demoted.push(asset.id);
|
|
10301
|
-
plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
|
|
10302
|
-
continue;
|
|
10303
|
-
}
|
|
10304
|
-
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
10305
|
-
const got = await fetcher(asset.url);
|
|
10306
|
-
const mime = clean(got.mime) ?? asset.mime;
|
|
10307
|
-
if (mime === "text/html") {
|
|
10308
|
-
plan.demoted.push(asset.id);
|
|
10309
|
-
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
10310
|
-
continue;
|
|
10311
|
-
}
|
|
10312
|
-
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
10313
|
-
plan.files[path2] = got.bytes;
|
|
10314
|
-
plan.bakedCount += 1;
|
|
10315
|
-
plan.bakedBytes += got.bytes.length;
|
|
10316
|
-
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
10317
|
-
}
|
|
10318
|
-
return plan;
|
|
10319
|
-
}
|
|
10320
|
-
function mediaSummary(plan) {
|
|
10321
|
-
const linked = plan.media.filter((m) => m.policy === "link").length;
|
|
10322
|
-
const embedded = plan.media.filter((m) => m.policy === "embed").length;
|
|
10323
|
-
const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
|
|
10324
|
-
if (linked) parts.push(`${linked} linked`);
|
|
10325
|
-
if (embedded) parts.push(`${embedded} embedded`);
|
|
10326
|
-
if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
|
|
10327
|
-
return parts.join(", ");
|
|
10328
|
-
}
|
|
10329
|
-
function bakedName(id2, url, mime) {
|
|
10330
|
-
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
10331
|
-
const hash = createHash8("sha256").update(url).digest("hex").slice(0, 8);
|
|
10332
|
-
return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
|
|
10333
|
-
}
|
|
10334
|
-
function extOf(url) {
|
|
10335
|
-
const path2 = hostOf(url) === null ? url : new URL(url).pathname;
|
|
10336
|
-
const ext = extname3(path2).toLowerCase();
|
|
10337
|
-
return FILE_EXT.has(ext) ? ext : "";
|
|
10338
|
-
}
|
|
10339
|
-
var MIME_EXT = {
|
|
10340
|
-
"image/png": ".png",
|
|
10341
|
-
"image/jpeg": ".jpg",
|
|
10342
|
-
"image/gif": ".gif",
|
|
10343
|
-
"image/webp": ".webp",
|
|
10344
|
-
"image/avif": ".avif",
|
|
10345
|
-
"image/svg+xml": ".svg",
|
|
10346
|
-
"video/mp4": ".mp4",
|
|
10347
|
-
"video/webm": ".webm",
|
|
10348
|
-
"audio/mpeg": ".mp3",
|
|
10349
|
-
"application/pdf": ".pdf"
|
|
10350
|
-
};
|
|
10351
|
-
function extFor(mime) {
|
|
10352
|
-
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
10353
|
-
}
|
|
10354
|
-
function hostOf(url) {
|
|
10355
|
-
try {
|
|
10356
|
-
const u = new URL(url);
|
|
10357
|
-
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
10358
|
-
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
10359
|
-
} catch {
|
|
10360
|
-
return null;
|
|
10361
|
-
}
|
|
10362
|
-
}
|
|
10363
|
-
function clean(mime) {
|
|
10364
|
-
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
10365
|
-
}
|
|
10366
|
-
function size(bytes) {
|
|
10367
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
10368
|
-
const units = ["KB", "MB", "GB"];
|
|
10369
|
-
let n3 = bytes / 1024;
|
|
10370
|
-
let i = 0;
|
|
10371
|
-
while (n3 >= 1024 && i < units.length - 1) {
|
|
10372
|
-
n3 /= 1024;
|
|
10373
|
-
i += 1;
|
|
10374
|
-
}
|
|
10375
|
-
return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
|
|
10376
|
-
}
|
|
10377
|
-
async function fetchAsset(url) {
|
|
10378
|
-
if (/^(https?|data):/i.test(url)) {
|
|
10379
|
-
const res = await fetch(url);
|
|
10380
|
-
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
10381
|
-
return {
|
|
10382
|
-
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
10383
|
-
mime: res.headers.get("content-type") ?? void 0
|
|
10384
|
-
};
|
|
10385
|
-
}
|
|
10386
|
-
return { bytes: new Uint8Array(await readFile14(url)) };
|
|
10387
|
-
}
|
|
10388
|
-
|
|
10389
12418
|
// src/prefs.ts
|
|
10390
|
-
import { readFile as
|
|
10391
|
-
import { dirname as dirname3, join as
|
|
12419
|
+
import { readFile as readFile16 } from "node:fs/promises";
|
|
12420
|
+
import { dirname as dirname3, join as join15, resolve as resolve4 } from "node:path";
|
|
10392
12421
|
var CONFIG_FILE = "decksmith.config.json";
|
|
10393
12422
|
var PREF_KEYS = Object.keys(prefsSchema.shape);
|
|
10394
12423
|
var NARRATION_KEYS = Object.keys(prefsSchema.shape.narration.unwrap().shape);
|
|
@@ -10405,10 +12434,10 @@ async function loadPrefs(overrides = {}, cwd = process.cwd(), source) {
|
|
|
10405
12434
|
return parsed;
|
|
10406
12435
|
}
|
|
10407
12436
|
async function findConfig(from) {
|
|
10408
|
-
let dir =
|
|
12437
|
+
let dir = resolve4(from);
|
|
10409
12438
|
for (; ; ) {
|
|
10410
|
-
const path2 =
|
|
10411
|
-
const text2 = await
|
|
12439
|
+
const path2 = join15(dir, CONFIG_FILE);
|
|
12440
|
+
const text2 = await readFile16(path2, "utf8").catch(() => void 0);
|
|
10412
12441
|
if (text2 !== void 0) {
|
|
10413
12442
|
try {
|
|
10414
12443
|
return { path: path2, value: JSON.parse(text2) };
|
|
@@ -10466,8 +12495,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
10466
12495
|
if (!format) throw new Error("no deck-16x9 format");
|
|
10467
12496
|
const step = opts.onStep ?? (() => {
|
|
10468
12497
|
});
|
|
10469
|
-
const out =
|
|
10470
|
-
await
|
|
12498
|
+
const out = resolve5(outDir);
|
|
12499
|
+
await mkdir10(out, { recursive: true });
|
|
10471
12500
|
const speed = opts.speed ?? 1;
|
|
10472
12501
|
const fontCss = await refreshFont(storyboard, source, out, step);
|
|
10473
12502
|
const deck = emitDeck(storyboard, source, format, await deckRuntime(), {
|
|
@@ -10479,8 +12508,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
10479
12508
|
});
|
|
10480
12509
|
const files = [];
|
|
10481
12510
|
const write = async (name, text2) => {
|
|
10482
|
-
await
|
|
10483
|
-
files.push(
|
|
12511
|
+
await writeFile12(join16(out, name), text2);
|
|
12512
|
+
files.push(join16(out, name));
|
|
10484
12513
|
};
|
|
10485
12514
|
await write("index.html", deck.composition);
|
|
10486
12515
|
await write("hyperframes.json", HYPERFRAMES_JSON);
|
|
@@ -10509,8 +12538,8 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
10509
12538
|
}
|
|
10510
12539
|
if (deck.page) {
|
|
10511
12540
|
await write(DECK_PAGE, deck.page);
|
|
10512
|
-
await cp(playerBundle(),
|
|
10513
|
-
files.push(
|
|
12541
|
+
await cp(playerBundle(), join16(out, PLAYER_FILE));
|
|
12542
|
+
files.push(join16(out, PLAYER_FILE));
|
|
10514
12543
|
}
|
|
10515
12544
|
files.push(...await vendorKatex(out));
|
|
10516
12545
|
if (opts.assetsFrom) files.push(...await copyAssets(opts.assetsFrom, out));
|
|
@@ -10519,7 +12548,7 @@ async function buildDeck(storyboard, source, outDir, opts = {}) {
|
|
|
10519
12548
|
}
|
|
10520
12549
|
const of = deck.cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
|
|
10521
12550
|
step(
|
|
10522
|
-
`build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${
|
|
12551
|
+
`build: ${deck.cut.kept.length}${of} beats at ${format.width}\xD7${format.height} \u2192 ${join16(out, "index.html")}`
|
|
10523
12552
|
);
|
|
10524
12553
|
for (const d of deck.cut.dropped) step(`build: cut ${d.beat.id} \u2014 ${d.reason}`);
|
|
10525
12554
|
for (const d of deck.cut.dangling) step(`build: check the wording \u2014 ${d.reason}`);
|
|
@@ -10537,65 +12566,65 @@ var HYPERFRAMES_JSON = `${JSON.stringify(
|
|
|
10537
12566
|
async function deckRuntime() {
|
|
10538
12567
|
const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
|
|
10539
12568
|
try {
|
|
10540
|
-
return await
|
|
12569
|
+
return await readFile17(path2, "utf8");
|
|
10541
12570
|
} catch {
|
|
10542
12571
|
throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
|
|
10543
12572
|
}
|
|
10544
12573
|
}
|
|
10545
12574
|
function playerBundle() {
|
|
10546
|
-
const require2 =
|
|
12575
|
+
const require2 = createRequire3(import.meta.url);
|
|
10547
12576
|
try {
|
|
10548
|
-
return
|
|
12577
|
+
return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
|
|
10549
12578
|
} catch {
|
|
10550
12579
|
throw new Error('Cannot locate the hyperframes player. Run "npm install".');
|
|
10551
12580
|
}
|
|
10552
12581
|
}
|
|
10553
12582
|
async function vendorKatex(out) {
|
|
10554
|
-
const require2 =
|
|
10555
|
-
const dist =
|
|
10556
|
-
const css = await
|
|
12583
|
+
const require2 = createRequire3(import.meta.url);
|
|
12584
|
+
const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
|
|
12585
|
+
const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
|
|
10557
12586
|
const written = [];
|
|
10558
|
-
await
|
|
10559
|
-
for (const file of await
|
|
12587
|
+
await mkdir10(join16(out, "katex/fonts"), { recursive: true });
|
|
12588
|
+
for (const file of await readdir4(join16(dist, "fonts"))) {
|
|
10560
12589
|
if (!file.endsWith(".woff2")) continue;
|
|
10561
|
-
await cp(
|
|
10562
|
-
written.push(
|
|
12590
|
+
await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
|
|
12591
|
+
written.push(join16(out, "katex/fonts", file));
|
|
10563
12592
|
}
|
|
10564
12593
|
const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
|
|
10565
12594
|
const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
|
|
10566
12595
|
return kept ? `src:${kept}` : whole;
|
|
10567
12596
|
});
|
|
10568
|
-
await
|
|
10569
|
-
written.push(
|
|
12597
|
+
await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
|
|
12598
|
+
written.push(join16(out, "katex/katex.min.css"));
|
|
10570
12599
|
return written;
|
|
10571
12600
|
}
|
|
10572
12601
|
async function copyAssets(sourceDir, out) {
|
|
10573
|
-
const from =
|
|
10574
|
-
if (!await
|
|
10575
|
-
await cp(from,
|
|
10576
|
-
return [
|
|
12602
|
+
const from = join16(resolve5(sourceDir), "assets");
|
|
12603
|
+
if (!await stat2(from).catch(() => null)) return [];
|
|
12604
|
+
await cp(from, join16(out, "assets"), { recursive: true });
|
|
12605
|
+
return [join16(out, "assets")];
|
|
10577
12606
|
}
|
|
10578
12607
|
async function copyAudio(from, narration, out) {
|
|
10579
|
-
const dir =
|
|
10580
|
-
await
|
|
12608
|
+
const dir = join16(out, narration.dir);
|
|
12609
|
+
await mkdir10(dir, { recursive: true });
|
|
10581
12610
|
const names = [
|
|
10582
12611
|
...new Set(
|
|
10583
12612
|
Object.values(narration.beats).flat().map((s) => s.audio)
|
|
10584
12613
|
)
|
|
10585
12614
|
].sort();
|
|
10586
12615
|
for (const name of names) {
|
|
10587
|
-
await cp(
|
|
12616
|
+
await cp(join16(resolve5(from), name), join16(dir, name)).catch(() => {
|
|
10588
12617
|
throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
|
|
10589
12618
|
});
|
|
10590
12619
|
}
|
|
10591
|
-
return names.map((n3) =>
|
|
12620
|
+
return names.map((n3) => join16(dir, n3));
|
|
10592
12621
|
}
|
|
10593
12622
|
async function refreshFont(storyboard, source, out, step) {
|
|
10594
12623
|
try {
|
|
10595
12624
|
const bundle = await bundleFont(
|
|
10596
12625
|
storyboard.lang,
|
|
10597
12626
|
JSON.stringify(source) + JSON.stringify(storyboard),
|
|
10598
|
-
|
|
12627
|
+
join16(out, "assets", "fonts")
|
|
10599
12628
|
);
|
|
10600
12629
|
if (bundle) step(`build: font bundle covers ${bundle.family}`);
|
|
10601
12630
|
return bundle?.css;
|
|
@@ -10636,6 +12665,7 @@ export {
|
|
|
10636
12665
|
annotatedFigureParamsSchema,
|
|
10637
12666
|
assertInsideResolves,
|
|
10638
12667
|
assertRefsResolve,
|
|
12668
|
+
attachClips,
|
|
10639
12669
|
barCompareParamsSchema,
|
|
10640
12670
|
beatRoleSchema,
|
|
10641
12671
|
beatSchema,
|
|
@@ -10659,15 +12689,18 @@ export {
|
|
|
10659
12689
|
equationSchema,
|
|
10660
12690
|
equationWalkParamsSchema,
|
|
10661
12691
|
fetchFigures,
|
|
12692
|
+
fetchGuarded,
|
|
10662
12693
|
figureSchema,
|
|
10663
12694
|
framePlan,
|
|
10664
12695
|
gradeOverprint,
|
|
10665
12696
|
gridParamsSchema,
|
|
12697
|
+
harvest,
|
|
10666
12698
|
hasIllustrations,
|
|
10667
12699
|
illustrate,
|
|
10668
12700
|
illustrationSchema,
|
|
10669
12701
|
imageChain,
|
|
10670
12702
|
insideSchema,
|
|
12703
|
+
isBlockedAddress,
|
|
10671
12704
|
isCustom,
|
|
10672
12705
|
lineChartParamsSchema,
|
|
10673
12706
|
loadPrefs,
|
|
@@ -10724,6 +12757,7 @@ export {
|
|
|
10724
12757
|
tempoChain,
|
|
10725
12758
|
termSchema,
|
|
10726
12759
|
titleParamsSchema,
|
|
12760
|
+
toMarkdown,
|
|
10727
12761
|
toSrt,
|
|
10728
12762
|
verify,
|
|
10729
12763
|
warnSecondsFor,
|