@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/cli.js
CHANGED
|
@@ -1,12 +1,247 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { cp, mkdir as
|
|
4
|
+
import { cp, mkdir as mkdir10, mkdtemp as mkdtemp4, readdir as readdir4, readFile as readFile17, rm as rm9, stat as stat2, writeFile as writeFile12 } from "node:fs/promises";
|
|
5
5
|
import { createRequire as createRequire3 } from "node:module";
|
|
6
|
-
import {
|
|
6
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
7
|
+
import { dirname as dirname4, join as join16, relative as relative2, resolve as resolve5 } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { Command } from "commander";
|
|
9
10
|
|
|
11
|
+
// src/pack/media.ts
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { extname } from "node:path";
|
|
15
|
+
var PLAYERS = [
|
|
16
|
+
"youtube.com",
|
|
17
|
+
"youtube-nocookie.com",
|
|
18
|
+
"youtu.be",
|
|
19
|
+
"vimeo.com",
|
|
20
|
+
"dailymotion.com",
|
|
21
|
+
"dai.ly",
|
|
22
|
+
"twitch.tv",
|
|
23
|
+
"loom.com",
|
|
24
|
+
"wistia.com",
|
|
25
|
+
"wistia.net",
|
|
26
|
+
"streamable.com",
|
|
27
|
+
"bilibili.com",
|
|
28
|
+
"soundcloud.com",
|
|
29
|
+
"tiktok.com"
|
|
30
|
+
];
|
|
31
|
+
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
32
|
+
".png",
|
|
33
|
+
".jpg",
|
|
34
|
+
".jpeg",
|
|
35
|
+
".gif",
|
|
36
|
+
".webp",
|
|
37
|
+
".avif",
|
|
38
|
+
".svg",
|
|
39
|
+
".mp4",
|
|
40
|
+
".webm",
|
|
41
|
+
".mov",
|
|
42
|
+
".m4v",
|
|
43
|
+
".mp3",
|
|
44
|
+
".m4a",
|
|
45
|
+
".wav",
|
|
46
|
+
".ogg",
|
|
47
|
+
".opus",
|
|
48
|
+
".pdf"
|
|
49
|
+
]);
|
|
50
|
+
function isEmbed(url2) {
|
|
51
|
+
const host = hostOf(url2);
|
|
52
|
+
return host !== null && PLAYERS.some((p) => host === p || host.endsWith(`.${p}`));
|
|
53
|
+
}
|
|
54
|
+
function segments(u) {
|
|
55
|
+
return u.pathname.split("/").filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
function token(raw2) {
|
|
58
|
+
return raw2 && /^[A-Za-z0-9_-]{1,64}$/.test(raw2) ? raw2 : void 0;
|
|
59
|
+
}
|
|
60
|
+
var YOUTUBE_EMBED = "https://www.youtube-nocookie.com";
|
|
61
|
+
var youtube = (id2) => id2 === void 0 ? void 0 : `${YOUTUBE_EMBED}/embed/${id2}`;
|
|
62
|
+
var YOUTUBE = {
|
|
63
|
+
origin: YOUTUBE_EMBED,
|
|
64
|
+
embed: (u) => {
|
|
65
|
+
const seg = segments(u);
|
|
66
|
+
const path2 = seg[0];
|
|
67
|
+
return youtube(
|
|
68
|
+
token(
|
|
69
|
+
path2 === "watch" ? u.searchParams.get("v") : path2 === "embed" || path2 === "shorts" || path2 === "live" || path2 === "v" ? seg[1] : void 0
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var YOUTU_BE = {
|
|
75
|
+
origin: YOUTUBE_EMBED,
|
|
76
|
+
embed: (u) => youtube(token(segments(u)[0]))
|
|
77
|
+
};
|
|
78
|
+
var VIMEO_EMBED = "https://player.vimeo.com";
|
|
79
|
+
var VIMEO = {
|
|
80
|
+
origin: VIMEO_EMBED,
|
|
81
|
+
embed: (u) => {
|
|
82
|
+
const seg = segments(u);
|
|
83
|
+
const [head, next] = seg[0] === "video" ? [seg[1], seg[2]] : [seg[0], seg[1]];
|
|
84
|
+
if (head === void 0 || !/^\d+$/.test(head)) return void 0;
|
|
85
|
+
const hash = token(next ?? u.searchParams.get("h"));
|
|
86
|
+
return `${VIMEO_EMBED}/video/${head}?${hash ? `h=${hash}&` : ""}dnt=1`;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var DAILYMOTION_EMBED = "https://www.dailymotion.com";
|
|
90
|
+
var dailymotion = (id2) => id2 === void 0 ? void 0 : `${DAILYMOTION_EMBED}/embed/video/${id2}`;
|
|
91
|
+
var DAILYMOTION = {
|
|
92
|
+
origin: DAILYMOTION_EMBED,
|
|
93
|
+
embed: (u) => {
|
|
94
|
+
const seg = segments(u);
|
|
95
|
+
return dailymotion(
|
|
96
|
+
token(seg[0] === "video" ? seg[1] : seg[0] === "embed" ? seg[2] : void 0)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
var DAI_LY = {
|
|
101
|
+
origin: DAILYMOTION_EMBED,
|
|
102
|
+
embed: (u) => dailymotion(token(segments(u)[0]))
|
|
103
|
+
};
|
|
104
|
+
var LOOM = {
|
|
105
|
+
origin: "https://www.loom.com",
|
|
106
|
+
embed: (u) => {
|
|
107
|
+
const seg = segments(u);
|
|
108
|
+
const id2 = token(seg[0] === "share" || seg[0] === "embed" ? seg[1] : void 0);
|
|
109
|
+
return id2 === void 0 ? void 0 : `https://www.loom.com/embed/${id2}`;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var EMBEDS = {
|
|
113
|
+
"youtube.com": YOUTUBE,
|
|
114
|
+
"youtube-nocookie.com": YOUTUBE,
|
|
115
|
+
"youtu.be": YOUTU_BE,
|
|
116
|
+
"vimeo.com": VIMEO,
|
|
117
|
+
"dailymotion.com": DAILYMOTION,
|
|
118
|
+
"dai.ly": DAI_LY,
|
|
119
|
+
"loom.com": LOOM
|
|
120
|
+
};
|
|
121
|
+
var EMBED_ORIGINS = [
|
|
122
|
+
...new Set(Object.values(EMBEDS).map((e) => e.origin))
|
|
123
|
+
];
|
|
124
|
+
function embedUrl(url2) {
|
|
125
|
+
const host = hostOf(url2);
|
|
126
|
+
if (host === null) return void 0;
|
|
127
|
+
let u;
|
|
128
|
+
try {
|
|
129
|
+
u = new URL(url2);
|
|
130
|
+
} catch {
|
|
131
|
+
return void 0;
|
|
132
|
+
}
|
|
133
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return void 0;
|
|
134
|
+
const rule = Object.entries(EMBEDS).find(([h]) => host === h || host.endsWith(`.${h}`))?.[1];
|
|
135
|
+
return rule?.embed(u);
|
|
136
|
+
}
|
|
137
|
+
function policyFor(url2, prefer) {
|
|
138
|
+
if (isEmbed(url2)) return "embed";
|
|
139
|
+
const host = hostOf(url2);
|
|
140
|
+
if (host === null || /^data:/i.test(url2)) return "bake";
|
|
141
|
+
if (prefer === "link") return "link";
|
|
142
|
+
return FILE_EXT.has(extOf(url2)) ? "bake" : "link";
|
|
143
|
+
}
|
|
144
|
+
async function planMedia(assets, fetcher = fetchAsset) {
|
|
145
|
+
const plan = {
|
|
146
|
+
media: [],
|
|
147
|
+
files: {},
|
|
148
|
+
bakedCount: 0,
|
|
149
|
+
bakedBytes: 0,
|
|
150
|
+
demoted: [],
|
|
151
|
+
promoted: []
|
|
152
|
+
};
|
|
153
|
+
for (const asset of assets) {
|
|
154
|
+
const policy = policyFor(asset.url, asset.prefer);
|
|
155
|
+
if (policy !== "bake") {
|
|
156
|
+
if (asset.prefer === "bake") plan.demoted.push(asset.id);
|
|
157
|
+
plan.media.push({ id: asset.id, policy, url: asset.url, mime: asset.mime });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
161
|
+
const got = await fetcher(asset.url);
|
|
162
|
+
const mime = clean(got.mime) ?? asset.mime;
|
|
163
|
+
if (mime === "text/html") {
|
|
164
|
+
plan.demoted.push(asset.id);
|
|
165
|
+
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
169
|
+
plan.files[path2] = got.bytes;
|
|
170
|
+
plan.bakedCount += 1;
|
|
171
|
+
plan.bakedBytes += got.bytes.length;
|
|
172
|
+
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
173
|
+
}
|
|
174
|
+
return plan;
|
|
175
|
+
}
|
|
176
|
+
function mediaSummary(plan) {
|
|
177
|
+
const linked = plan.media.filter((m) => m.policy === "link").length;
|
|
178
|
+
const embedded = plan.media.filter((m) => m.policy === "embed").length;
|
|
179
|
+
const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
|
|
180
|
+
if (linked) parts.push(`${linked} linked`);
|
|
181
|
+
if (embedded) parts.push(`${embedded} embedded`);
|
|
182
|
+
if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
|
|
183
|
+
return parts.join(", ");
|
|
184
|
+
}
|
|
185
|
+
function bakedName(id2, url2, mime) {
|
|
186
|
+
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
187
|
+
const hash = createHash("sha256").update(url2).digest("hex").slice(0, 8);
|
|
188
|
+
return `${slug}-${hash}${extOf(url2) || extFor(mime)}`;
|
|
189
|
+
}
|
|
190
|
+
function extOf(url2) {
|
|
191
|
+
const path2 = hostOf(url2) === null ? url2 : new URL(url2).pathname;
|
|
192
|
+
const ext = extname(path2).toLowerCase();
|
|
193
|
+
return FILE_EXT.has(ext) ? ext : "";
|
|
194
|
+
}
|
|
195
|
+
var MIME_EXT = {
|
|
196
|
+
"image/png": ".png",
|
|
197
|
+
"image/jpeg": ".jpg",
|
|
198
|
+
"image/gif": ".gif",
|
|
199
|
+
"image/webp": ".webp",
|
|
200
|
+
"image/avif": ".avif",
|
|
201
|
+
"image/svg+xml": ".svg",
|
|
202
|
+
"video/mp4": ".mp4",
|
|
203
|
+
"video/webm": ".webm",
|
|
204
|
+
"audio/mpeg": ".mp3",
|
|
205
|
+
"application/pdf": ".pdf"
|
|
206
|
+
};
|
|
207
|
+
function extFor(mime) {
|
|
208
|
+
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
209
|
+
}
|
|
210
|
+
function hostOf(url2) {
|
|
211
|
+
try {
|
|
212
|
+
const u = new URL(url2);
|
|
213
|
+
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
214
|
+
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function clean(mime) {
|
|
220
|
+
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
221
|
+
}
|
|
222
|
+
function size(bytes) {
|
|
223
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
224
|
+
const units = ["KB", "MB", "GB"];
|
|
225
|
+
let n3 = bytes / 1024;
|
|
226
|
+
let i = 0;
|
|
227
|
+
while (n3 >= 1024 && i < units.length - 1) {
|
|
228
|
+
n3 /= 1024;
|
|
229
|
+
i += 1;
|
|
230
|
+
}
|
|
231
|
+
return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
|
|
232
|
+
}
|
|
233
|
+
async function fetchAsset(url2) {
|
|
234
|
+
if (/^(https?|data):/i.test(url2)) {
|
|
235
|
+
const res = await fetch(url2);
|
|
236
|
+
if (!res.ok) throw new Error(`${url2}: HTTP ${res.status}`);
|
|
237
|
+
return {
|
|
238
|
+
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
239
|
+
mime: res.headers.get("content-type") ?? void 0
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { bytes: new Uint8Array(await readFile(url2)) };
|
|
243
|
+
}
|
|
244
|
+
|
|
10
245
|
// src/types.ts
|
|
11
246
|
import { z } from "zod";
|
|
12
247
|
var refSchema = z.object({
|
|
@@ -15,9 +250,34 @@ var refSchema = z.object({
|
|
|
15
250
|
});
|
|
16
251
|
var figureSchema = z.object({
|
|
17
252
|
id: z.string(),
|
|
253
|
+
/**
|
|
254
|
+
* WHAT THE ASSET IS. A CLIP IS A FIGURE.
|
|
255
|
+
*
|
|
256
|
+
* A harvested page hands back stills and video in one pass, and the obvious
|
|
257
|
+
* shape for the video — a fifth `Source` array and a fifth `refSchema` kind —
|
|
258
|
+
* costs every layer that already knows what a figure is: the inventory
|
|
259
|
+
* `renderSource` prints, `assertRefsResolve`, the archetypes that take a
|
|
260
|
+
* `figureId`, and the pack. All of them would need a second word for "the
|
|
261
|
+
* thing this beat points at", and `refSchema` is a CLOSED enum of four kinds
|
|
262
|
+
* that a fifth entry would put into every stored plan's schema. A clip is a
|
|
263
|
+
* rectangle with intrinsic pixels and a caption that a beat points at, which
|
|
264
|
+
* is what a figure is; what differs is one branch at emit.
|
|
265
|
+
*
|
|
266
|
+
* DEFAULTED rather than required, and that is the whole point of the field
|
|
267
|
+
* being an enum with a default: every `source.json` written before clips
|
|
268
|
+
* existed parses unchanged and comes back an `image`, which is what it has
|
|
269
|
+
* always been.
|
|
270
|
+
*/
|
|
271
|
+
kind: z.enum(["image", "clip"]).default("image"),
|
|
18
272
|
/** Path relative to the deck's asset directory. */
|
|
19
273
|
src: z.string(),
|
|
20
274
|
caption: z.string(),
|
|
275
|
+
/**
|
|
276
|
+
* The intrinsic pixel size layout keys off — for a clip, the VIDEO's own
|
|
277
|
+
* dimensions, not the poster's. Every fit, crop and leader-line fraction
|
|
278
|
+
* downstream is expressed against this box, so a clip whose poster was
|
|
279
|
+
* letterboxed to another shape would put every annotation in the wrong place.
|
|
280
|
+
*/
|
|
21
281
|
width: z.int().positive(),
|
|
22
282
|
height: z.int().positive(),
|
|
23
283
|
/**
|
|
@@ -35,7 +295,26 @@ var figureSchema = z.object({
|
|
|
35
295
|
*/
|
|
36
296
|
sectionId: z.string().optional(),
|
|
37
297
|
/** The sentence or paragraph that refers to it, verbatim from the document. */
|
|
38
|
-
mention: z.string().optional()
|
|
298
|
+
mention: z.string().optional(),
|
|
299
|
+
// THE THREE FIELDS ONLY A CLIP USES. All optional, for the same reason `kind`
|
|
300
|
+
// is defaulted: an image carries none of them, and a source written before
|
|
301
|
+
// clips existed parses into exactly the object it always did.
|
|
302
|
+
/**
|
|
303
|
+
* The local still that represents the clip — what the deck shows before
|
|
304
|
+
* anyone presses play, and what stands in wherever the video cannot run at
|
|
305
|
+
* all. A clip whose video could not be downloaded is this still and nothing
|
|
306
|
+
* else, so it is the picture the beat is really planned around.
|
|
307
|
+
*/
|
|
308
|
+
poster: z.string().optional(),
|
|
309
|
+
/** How long the clip runs. Seconds, as measured off the file, never guessed. */
|
|
310
|
+
seconds: z.number().positive().optional(),
|
|
311
|
+
/**
|
|
312
|
+
* The page the video lives ON, when the video itself is not a file we can
|
|
313
|
+
* fetch — a player page, an embed, anything whose terms or DRM make the bytes
|
|
314
|
+
* unavailable. Then `poster` is all the deck can show, and this is where a
|
|
315
|
+
* viewer goes to watch the thing. Absent for a clip we hold the file for.
|
|
316
|
+
*/
|
|
317
|
+
href: z.string().optional()
|
|
39
318
|
});
|
|
40
319
|
var equationSchema = z.object({
|
|
41
320
|
id: z.string(),
|
|
@@ -707,22 +986,22 @@ function resizeFormat(base, width, height) {
|
|
|
707
986
|
}
|
|
708
987
|
|
|
709
988
|
// src/plan/select.ts
|
|
710
|
-
function selectBeats(storyboard,
|
|
989
|
+
function selectBeats(storyboard, budget3, seconds = {}) {
|
|
711
990
|
const len = (b) => seconds[b.id] ?? b.seconds;
|
|
712
991
|
const dropped = [];
|
|
713
|
-
const where2 =
|
|
992
|
+
const where2 = budget3.id ? `${budget3.id}'s` : "the";
|
|
714
993
|
const live = storyboard.beats.filter((b) => {
|
|
715
|
-
if (b.weight >=
|
|
994
|
+
if (b.weight >= budget3.minWeight) return true;
|
|
716
995
|
dropped.push({
|
|
717
996
|
beat: b,
|
|
718
997
|
seconds: len(b),
|
|
719
998
|
rule: "below_min_weight",
|
|
720
|
-
reason: `Weight ${b.weight} is below ${where2} floor of ${
|
|
999
|
+
reason: `Weight ${b.weight} is below ${where2} floor of ${budget3.minWeight}.`
|
|
721
1000
|
});
|
|
722
1001
|
return false;
|
|
723
1002
|
});
|
|
724
1003
|
const total = live.reduce((s, b) => s + len(b), 0);
|
|
725
|
-
const cap =
|
|
1004
|
+
const cap = budget3.maxSeconds ?? Number.POSITIVE_INFINITY;
|
|
726
1005
|
if (!Number.isFinite(cap) || total <= cap) {
|
|
727
1006
|
return finish(live, dropped, storyboard, len);
|
|
728
1007
|
}
|
|
@@ -734,9 +1013,9 @@ function selectBeats(storyboard, budget2, seconds = {}) {
|
|
|
734
1013
|
);
|
|
735
1014
|
const all = knapsack(live, len, cap, ends) ?? knapsack(live, len, cap, /* @__PURE__ */ new Set());
|
|
736
1015
|
const chosen = all ?? [live[0]];
|
|
737
|
-
return budgetDrops(live, chosen, dropped, storyboard, len, cap,
|
|
1016
|
+
return budgetDrops(live, chosen, dropped, storyboard, len, cap, budget3, false);
|
|
738
1017
|
}
|
|
739
|
-
return budgetDrops(live, keep, dropped, storyboard, len, cap,
|
|
1018
|
+
return budgetDrops(live, keep, dropped, storyboard, len, cap, budget3, true);
|
|
740
1019
|
}
|
|
741
1020
|
function protect(live, len, cap) {
|
|
742
1021
|
const ids = /* @__PURE__ */ new Set();
|
|
@@ -821,7 +1100,7 @@ function knapsack(live, len, cap, locked) {
|
|
|
821
1100
|
}
|
|
822
1101
|
return kept;
|
|
823
1102
|
}
|
|
824
|
-
function budgetDrops(live, chosen, dropped, storyboard, len, cap,
|
|
1103
|
+
function budgetDrops(live, chosen, dropped, storyboard, len, cap, budget3, fits) {
|
|
825
1104
|
const keptIds = new Set(chosen.map((b) => b.id));
|
|
826
1105
|
const families = /* @__PURE__ */ new Map();
|
|
827
1106
|
for (const b of chosen) {
|
|
@@ -829,7 +1108,7 @@ function budgetDrops(live, chosen, dropped, storyboard, len, cap, budget2, fits)
|
|
|
829
1108
|
families.set(fam, (families.get(fam) ?? 0) + 1);
|
|
830
1109
|
}
|
|
831
1110
|
const lightest = Math.min(...chosen.map((b) => b.weight));
|
|
832
|
-
const target =
|
|
1111
|
+
const target = budget3.id ? `${budget3.id}'s ${clock(cap)}` : clock(cap);
|
|
833
1112
|
for (const b of live) {
|
|
834
1113
|
if (keptIds.has(b.id)) continue;
|
|
835
1114
|
const fam = ARCHETYPE_FAMILY[b.archetype];
|
|
@@ -908,8 +1187,8 @@ function clock(seconds) {
|
|
|
908
1187
|
}
|
|
909
1188
|
|
|
910
1189
|
// src/source/fonts.ts
|
|
911
|
-
import { createHash } from "node:crypto";
|
|
912
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1190
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1191
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
913
1192
|
import { join } from "node:path";
|
|
914
1193
|
function familyFor(lang) {
|
|
915
1194
|
const tag = lang.toLowerCase();
|
|
@@ -923,11 +1202,11 @@ async function bundleFont(lang, glyphs2, dir) {
|
|
|
923
1202
|
const family = familyFor(lang);
|
|
924
1203
|
if (!family) return null;
|
|
925
1204
|
const text2 = [...new Set(glyphs2)].filter((c) => c > " ").sort().join("");
|
|
926
|
-
const stamp = `/* decksmith ${
|
|
1205
|
+
const stamp = `/* decksmith ${createHash2("sha256").update(`${family}
|
|
927
1206
|
${text2}`).digest("hex").slice(0, 16)} */`;
|
|
928
1207
|
await mkdir(dir, { recursive: true });
|
|
929
1208
|
const cssPath = join(dir, "fonts.css");
|
|
930
|
-
const cached = await
|
|
1209
|
+
const cached = await readFile2(cssPath, "utf8").catch(() => "");
|
|
931
1210
|
if (cached.startsWith(stamp)) return { family, css: cached, files: localNames(cached) };
|
|
932
1211
|
const res = await fetch(
|
|
933
1212
|
`https://fonts.googleapis.com/css2?family=${family.replaceAll(" ", "+")}:wght@400;500;700&text=${encodeURIComponent(text2)}&display=block`,
|
|
@@ -938,12 +1217,12 @@ ${text2}`).digest("hex").slice(0, 16)} */`;
|
|
|
938
1217
|
const remote = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1] ?? ""))];
|
|
939
1218
|
const slug = family.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
940
1219
|
const files = [];
|
|
941
|
-
for (const [i,
|
|
942
|
-
const font = await fetch(
|
|
943
|
-
if (!font.ok) throw new Error(`${
|
|
1220
|
+
for (const [i, url2] of remote.entries()) {
|
|
1221
|
+
const font = await fetch(url2);
|
|
1222
|
+
if (!font.ok) throw new Error(`${url2}: HTTP ${font.status}`);
|
|
944
1223
|
const name = `${slug}-${i}.woff2`;
|
|
945
1224
|
await writeFile(join(dir, name), Buffer.from(await font.arrayBuffer()));
|
|
946
|
-
css = css.replaceAll(
|
|
1225
|
+
css = css.replaceAll(url2, name);
|
|
947
1226
|
files.push(name);
|
|
948
1227
|
}
|
|
949
1228
|
css = `${stamp}
|
|
@@ -1809,11 +2088,11 @@ function unwidow(text2, width, size3, weight = 700, face = "latin") {
|
|
|
1809
2088
|
function wrapTokens(tokens, size3, width, weight, face) {
|
|
1810
2089
|
const lines = [];
|
|
1811
2090
|
let line2 = "";
|
|
1812
|
-
for (const
|
|
1813
|
-
const candidate = line2 ? `${line2} ${
|
|
2091
|
+
for (const token2 of tokens) {
|
|
2092
|
+
const candidate = line2 ? `${line2} ${token2}` : token2;
|
|
1814
2093
|
if (line2 && textWidth(candidate, size3, weight, 0, false, face) > width) {
|
|
1815
2094
|
lines.push(line2);
|
|
1816
|
-
line2 =
|
|
2095
|
+
line2 = token2;
|
|
1817
2096
|
continue;
|
|
1818
2097
|
}
|
|
1819
2098
|
line2 = candidate;
|
|
@@ -2163,6 +2442,11 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2163
2442
|
`annotated-figure ${beat.id}: no figure "${p.figureId}" in source ${ctx.source.id}`
|
|
2164
2443
|
);
|
|
2165
2444
|
}
|
|
2445
|
+
if (fig.kind === "clip") {
|
|
2446
|
+
throw new Error(
|
|
2447
|
+
`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`
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2166
2450
|
const crop = p.crop;
|
|
2167
2451
|
const view = crop ? { width: fig.width * crop.w, height: fig.height * crop.h } : { width: fig.width, height: fig.height };
|
|
2168
2452
|
const clamp3 = (v) => Math.min(1, Math.max(0, v));
|
|
@@ -2173,15 +2457,15 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2173
2457
|
})) : p.notes;
|
|
2174
2458
|
const STAGE_W = contentW(ctx.format);
|
|
2175
2459
|
const face = faceOf(ctx.theme.fontStack);
|
|
2176
|
-
const
|
|
2177
|
-
if (
|
|
2460
|
+
const budget3 = stageBudget(ctx.format, p.eyebrow, p.headline, fig.caption, face);
|
|
2461
|
+
if (budget3 < MIN_STAGE) {
|
|
2178
2462
|
throw new Error(
|
|
2179
|
-
`annotated-figure ${beat.id}: the headline and caption leave ${Math.round(
|
|
2463
|
+
`annotated-figure ${beat.id}: the headline and caption leave ${Math.round(budget3)}px for the figure, under the ${MIN_STAGE}px floor \u2014 shorten the headline or split the beat`
|
|
2180
2464
|
);
|
|
2181
2465
|
}
|
|
2182
|
-
const plan = planFigure(STAGE_W, notes, view,
|
|
2466
|
+
const plan = planFigure(STAGE_W, notes, view, budget3, isPortrait(ctx.format), face);
|
|
2183
2467
|
const stageH = plan.height;
|
|
2184
|
-
const
|
|
2468
|
+
const plate2 = {
|
|
2185
2469
|
x: plan.img.x - PLATE,
|
|
2186
2470
|
y: plan.img.y - PLATE,
|
|
2187
2471
|
w: plan.img.w + 2 * PLATE,
|
|
@@ -2335,7 +2619,7 @@ var annotatedFigure = (beat, ctx) => {
|
|
|
2335
2619
|
// Per-scene geometry: the overlay is only correct because the image box is
|
|
2336
2620
|
// stated rather than negotiated with the layout engine.
|
|
2337
2621
|
`#${sid}-stage{width:${STAGE_W}px;height:${n(stageH)}px;margin-top:${STAGE_GAP}px}`,
|
|
2338
|
-
`#${sid}-plate{left:${n(
|
|
2622
|
+
`#${sid}-plate{left:${n(plate2.x)}px;top:${n(plate2.y)}px;width:${n(plate2.w)}px;height:${n(plate2.h)}px}`,
|
|
2339
2623
|
// `overflow:visible` because a dot sitting on the figure's own edge puts its
|
|
2340
2624
|
// halo outside the viewBox, and a clipped halo reads as a rendering fault.
|
|
2341
2625
|
`#${sid}-ov{position:absolute;left:0;top:0;overflow:visible}`,
|
|
@@ -2698,7 +2982,7 @@ var callout = (beat, ctx) => {
|
|
|
2698
2982
|
});
|
|
2699
2983
|
const stackedH = heights.reduce((a, b) => a + b, 0) + PANEL_GAP * (heights.length - 1);
|
|
2700
2984
|
const need = cols === 1 ? stackedH : Math.max(...heights);
|
|
2701
|
-
const
|
|
2985
|
+
const budget3 = bodyBudget(
|
|
2702
2986
|
ctx.format,
|
|
2703
2987
|
p.eyebrow,
|
|
2704
2988
|
p.headline,
|
|
@@ -2707,12 +2991,12 @@ var callout = (beat, ctx) => {
|
|
|
2707
2991
|
void 0,
|
|
2708
2992
|
face
|
|
2709
2993
|
);
|
|
2710
|
-
if (need >
|
|
2994
|
+
if (need > budget3) {
|
|
2711
2995
|
throw new Error(
|
|
2712
|
-
`callout ${beat.id}: ${Math.round(need)}px of panel in a ${Math.round(
|
|
2996
|
+
`callout ${beat.id}: ${Math.round(need)}px of panel in a ${Math.round(budget3)}px box \u2014 shorten the lines or split the beat`
|
|
2713
2997
|
);
|
|
2714
2998
|
}
|
|
2715
|
-
const cap = Math.min(
|
|
2999
|
+
const cap = Math.min(budget3, Math.round(need * (cols === 1 ? 1.1 : 1.22)));
|
|
2716
3000
|
const note2 = p.note ? `
|
|
2717
3001
|
<div class="conote" id="${sid}-note">${esc(p.note)}</div>` : "";
|
|
2718
3002
|
const html = `${chrome(sid, p.eyebrow, p.headline, box, face)}
|
|
@@ -2791,6 +3075,31 @@ var CLAIM_LH = 1.5;
|
|
|
2791
3075
|
var CLAIM_RULE = 6 + 32;
|
|
2792
3076
|
var BESIDE_COL = 560;
|
|
2793
3077
|
var MIN_PLATE = 2 * Math.round(BODY_SIZE * BODY_LH);
|
|
3078
|
+
function plate(fig, sid, beatId, start) {
|
|
3079
|
+
const img = (src) => ({
|
|
3080
|
+
html: `<img src="assets/${esc(src)}" alt="${esc(fig.caption)}" />`,
|
|
3081
|
+
el: "img"
|
|
3082
|
+
});
|
|
3083
|
+
if (fig.kind !== "clip") return img(fig.src);
|
|
3084
|
+
if (fig.href !== void 0) {
|
|
3085
|
+
if (fig.poster === void 0) {
|
|
3086
|
+
throw new Error(
|
|
3087
|
+
`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`
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
return img(fig.poster);
|
|
3091
|
+
}
|
|
3092
|
+
if (start === void 0) {
|
|
3093
|
+
throw new Error(
|
|
3094
|
+
`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`
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
const poster = fig.poster === void 0 ? "" : ` poster="assets/${esc(fig.poster)}"`;
|
|
3098
|
+
return {
|
|
3099
|
+
html: `<video id="${sid}-v" src="assets/${esc(fig.src)}"${poster} data-start="${start}" preload="auto" playsinline muted></video>`,
|
|
3100
|
+
el: "video"
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
2794
3103
|
var claimFigure = (beat, ctx) => {
|
|
2795
3104
|
const { sid, theme } = ctx;
|
|
2796
3105
|
const p = beat.params;
|
|
@@ -2830,7 +3139,8 @@ var claimFigure = (beat, ctx) => {
|
|
|
2830
3139
|
);
|
|
2831
3140
|
}
|
|
2832
3141
|
const claim = `<div class="claim" id="${sid}-c">${words(p.claim)}</div>`;
|
|
2833
|
-
const
|
|
3142
|
+
const held = plate(fig, sid, beat.id, ctx.start);
|
|
3143
|
+
const figure = `<div class="figwrap" id="${sid}-f">${held.html}</div>`;
|
|
2834
3144
|
const caption = `<div class="caption" id="${sid}-cap">${esc(fig.caption)}</div>`;
|
|
2835
3145
|
const body = tall ? `<div class="cf-stack">${claim}
|
|
2836
3146
|
<div>${figure}
|
|
@@ -2898,12 +3208,22 @@ ${body}`,
|
|
|
2898
3208
|
// Height-capped rather than width-driven: a square figure in the beside
|
|
2899
3209
|
// layout would otherwise be ~970px tall and run off the canvas. The cap is
|
|
2900
3210
|
// the measured remainder, not a constant — see `figMax`.
|
|
2901
|
-
|
|
3211
|
+
//
|
|
3212
|
+
// NAMED FOR THE TAG THAT IS ACTUALLY THERE rather than written for both.
|
|
3213
|
+
// A rule listing `img, video` would move the bytes of every deck we have
|
|
3214
|
+
// ever built to describe an element almost none of them contain, and a
|
|
3215
|
+
// rule naming only `img` over a clip is a cap that silently does not
|
|
3216
|
+
// apply — the video would render at its natural 1920x1080 and run off the
|
|
3217
|
+
// canvas, which is invariant-5 territory that no gate reads.
|
|
3218
|
+
`.figwrap ${held.el}{max-width:100%;max-height:${figMax}px;width:auto;height:auto;display:block}`,
|
|
2902
3219
|
`.caption{font-size:${BODY_SIZE}px;line-height:${BODY_LH};color:${theme.dim};margin-top:16px}`,
|
|
2903
3220
|
// The image, not its wrapper: the wrapper's entrance already writes
|
|
2904
3221
|
// `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
|
-
|
|
3222
|
+
// 16px padding absorbs — the swell can never reach the canvas edge. Same
|
|
3223
|
+
// reason as the cap above for naming the tag: a drift rule aimed at `img`
|
|
3224
|
+
// over a `<video>` is one ambient rule that animates nothing, and the
|
|
3225
|
+
// slide reads as dead rather than as held.
|
|
3226
|
+
ambient(sid, `-f ${held.el}`, DRIFT)
|
|
2907
3227
|
].join("\n")
|
|
2908
3228
|
};
|
|
2909
3229
|
};
|
|
@@ -2959,7 +3279,7 @@ var dataTable = (beat, ctx) => {
|
|
|
2959
3279
|
}
|
|
2960
3280
|
const rows = shown.length + 1;
|
|
2961
3281
|
const width = noteWidth(ctx.format);
|
|
2962
|
-
const
|
|
3282
|
+
const budget3 = bodyBudget(
|
|
2963
3283
|
ctx.format,
|
|
2964
3284
|
p.eyebrow,
|
|
2965
3285
|
p.headline,
|
|
@@ -2968,11 +3288,11 @@ var dataTable = (beat, ctx) => {
|
|
|
2968
3288
|
0,
|
|
2969
3289
|
face
|
|
2970
3290
|
);
|
|
2971
|
-
const spare =
|
|
3291
|
+
const spare = budget3 - rows * cell * 1.2;
|
|
2972
3292
|
const roomiest = isPortrait(ctx.format) ? PAD_Y_MAX.tall : PAD_Y_MAX.wide;
|
|
2973
3293
|
const padY = Math.round(Math.max(PAD_Y_MIN, Math.min(roomiest, spare / (2 * rows))));
|
|
2974
3294
|
const drawn = rows * (cell * 1.2 + 2 * padY);
|
|
2975
|
-
const canvas =
|
|
3295
|
+
const canvas = budget3 + PAD_Y;
|
|
2976
3296
|
if (drawn > canvas) {
|
|
2977
3297
|
const has = said === void 0 ? `has ${table.rows.length} rows, which` : `has ${table.rows.length} rows and this beat names ${shown.length} of them, which`;
|
|
2978
3298
|
const lever = said === void 0 ? `name the rows that carry the argument in params.rows \u2014 the slide draws those and states on itself how many it left out \u2014 or take the one column that carries it to bar-compare` : `name fewer rows in params.rows, or take the one column that carries the argument to bar-compare`;
|
|
@@ -3023,8 +3343,8 @@ var dataTable = (beat, ctx) => {
|
|
|
3023
3343
|
p.highlight.forEach((h, i) => {
|
|
3024
3344
|
const index = shown.findIndex((row) => row[0] === h.row);
|
|
3025
3345
|
if (index < 0) {
|
|
3026
|
-
const
|
|
3027
|
-
throw new Error(`data-table ${beat.id}: ${
|
|
3346
|
+
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}`;
|
|
3347
|
+
throw new Error(`data-table ${beat.id}: ${why3}`);
|
|
3028
3348
|
}
|
|
3029
3349
|
const at = settled + 0.3 + i * step2;
|
|
3030
3350
|
tl.push(
|
|
@@ -3339,14 +3659,14 @@ var MORPH_SECONDS = 1.6;
|
|
|
3339
3659
|
var equationMorph = (beat, ctx) => {
|
|
3340
3660
|
const { sid, theme } = ctx;
|
|
3341
3661
|
const p = beat.params;
|
|
3342
|
-
const
|
|
3662
|
+
const find3 = (id2) => {
|
|
3343
3663
|
const eq = ctx.source.equations.find((e) => e.id === id2);
|
|
3344
3664
|
if (!eq)
|
|
3345
3665
|
throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
|
|
3346
3666
|
return eq;
|
|
3347
3667
|
};
|
|
3348
|
-
const a =
|
|
3349
|
-
const b =
|
|
3668
|
+
const a = find3(p.fromId);
|
|
3669
|
+
const b = find3(p.toId);
|
|
3350
3670
|
const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
|
|
3351
3671
|
if (both.length === 0) {
|
|
3352
3672
|
throw new Error(
|
|
@@ -3492,8 +3812,8 @@ var grid = (beat, ctx) => {
|
|
|
3492
3812
|
void 0,
|
|
3493
3813
|
face
|
|
3494
3814
|
);
|
|
3495
|
-
const solve3 = (boxW,
|
|
3496
|
-
const cell = solveCell(p.cols, p.rows, boxW,
|
|
3815
|
+
const solve3 = (boxW, budget4) => {
|
|
3816
|
+
const cell = solveCell(p.cols, p.rows, boxW, budget4);
|
|
3497
3817
|
const gap = cell * GAP;
|
|
3498
3818
|
return {
|
|
3499
3819
|
cell,
|
|
@@ -3516,13 +3836,13 @@ var grid = (beat, ctx) => {
|
|
|
3516
3836
|
const widest2 = Math.max(...p.regions.map((r) => textWidth(r.label, LABEL, 600, 0, false, face)));
|
|
3517
3837
|
const oneLine = Math.min(620, widest2 * 1.06);
|
|
3518
3838
|
const wrapped = Math.min(620, widest2 / MAX_LINES * 1.12);
|
|
3519
|
-
const layout2 = (
|
|
3520
|
-
const bare = solve3(W,
|
|
3839
|
+
const layout2 = (budget4) => {
|
|
3840
|
+
const bare = solve3(W, budget4);
|
|
3521
3841
|
const free = W - bare.w - 2 * MARGIN;
|
|
3522
3842
|
const needsGutter = p.regions.some((r, i) => crowded[i] || !fitsInside(bare, r));
|
|
3523
3843
|
const lw2 = needsGutter ? Math.max(wrapped, Math.min(oneLine, free - 90)) : 0;
|
|
3524
3844
|
const gutter2 = needsGutter ? lw2 + 90 : 0;
|
|
3525
|
-
const f2 = needsGutter ? solve3(W - gutter2,
|
|
3845
|
+
const f2 = needsGutter ? solve3(W - gutter2, budget4) : bare;
|
|
3526
3846
|
const inGutter2 = p.regions.map((r, i) => crowded[i] || !fitsInside(f2, r));
|
|
3527
3847
|
const lines2 = (r) => wrap(r.label, LABEL, lw2, 600, 0, face).length;
|
|
3528
3848
|
const stackH2 = p.regions.filter((_, i) => inGutter2[i]).reduce((t2, r, i) => t2 + lines2(r) * LABEL * LH + (i > 0 ? 14 : 16), 0);
|
|
@@ -3531,9 +3851,9 @@ var grid = (beat, ctx) => {
|
|
|
3531
3851
|
const roomy = layout2(full);
|
|
3532
3852
|
const roomBeside = W - Math.ceil(roomy.f.w + 2 * MARGIN + roomy.gutter) - NOTE_GAP;
|
|
3533
3853
|
const beside = !isPortrait(ctx.format) && !!col && roomBeside >= col.min;
|
|
3534
|
-
const
|
|
3535
|
-
const { f, lw, gutter, inGutter, lines, stackH } = beside ? roomy : layout2(
|
|
3536
|
-
const H = Math.min(
|
|
3854
|
+
const budget3 = beside ? full : stackedBudget;
|
|
3855
|
+
const { f, lw, gutter, inGutter, lines, stackH } = beside ? roomy : layout2(budget3);
|
|
3856
|
+
const H = Math.min(budget3, Math.max(f.h + 2 * MARGIN, stackH));
|
|
3537
3857
|
const fx = MARGIN;
|
|
3538
3858
|
const fy = (H - f.h) / 2;
|
|
3539
3859
|
const cols = tracks(f.w, p.cols, f.gap, fx);
|
|
@@ -3556,10 +3876,10 @@ var grid = (beat, ctx) => {
|
|
|
3556
3876
|
h: r.h * y0.w + (r.h - 1) * f.gap + 2 * bleed
|
|
3557
3877
|
};
|
|
3558
3878
|
};
|
|
3559
|
-
const
|
|
3879
|
+
const boxes3 = p.regions.map(boxOf);
|
|
3560
3880
|
const lx = W - gutter + 40;
|
|
3561
3881
|
const outside = p.regions.map((r, i) => ({ r, i })).filter(({ i }) => inGutter[i]).map(({ r, i }) => {
|
|
3562
|
-
const b =
|
|
3882
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3563
3883
|
const half = lines(r) * LABEL * LH / 2;
|
|
3564
3884
|
return { i, half, y: b.y + b.h / 2, from: { x: b.x + b.w, y: b.y + b.h / 2 } };
|
|
3565
3885
|
}).sort((a, b) => a.y - b.y || a.i - b.i);
|
|
@@ -3577,7 +3897,7 @@ var grid = (beat, ctx) => {
|
|
|
3577
3897
|
const parts = {};
|
|
3578
3898
|
p.regions.forEach((r, i) => {
|
|
3579
3899
|
parts[`rgn${i}`] = r.label;
|
|
3580
|
-
const b =
|
|
3900
|
+
const b = boxes3[i] ?? { x: fx, y: fy, w: f.cell, h: f.cell };
|
|
3581
3901
|
const tone2 = theme.tones[r.tone];
|
|
3582
3902
|
rects.push(
|
|
3583
3903
|
`<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 +4005,7 @@ var grid = (beat, ctx) => {
|
|
|
3685
4005
|
tl.push(...dimCells);
|
|
3686
4006
|
p.regions.forEach((_, i) => {
|
|
3687
4007
|
const at = first + i * step2;
|
|
3688
|
-
const b =
|
|
4008
|
+
const b = boxes3[i] ?? { x: 0, y: 0, w: 0, h: 0 };
|
|
3689
4009
|
tl.push(
|
|
3690
4010
|
tween(
|
|
3691
4011
|
`#${id(sid, "rgn", i)}`,
|
|
@@ -3816,8 +4136,8 @@ var lineChart = (beat, ctx) => {
|
|
|
3816
4136
|
const tall = isPortrait(ctx.format);
|
|
3817
4137
|
const width = tall ? box : box - (p.readout ? CHART_GAP + READOUT_W : 0);
|
|
3818
4138
|
const below = tall && p.readout ? wrap(p.readout, BODY_SIZE, box, 400, 0, face).length * Math.round(BODY_SIZE * READOUT_LH) + CHART_GAP : 0;
|
|
3819
|
-
const
|
|
3820
|
-
const H = Math.round(tall ? Math.min(
|
|
4139
|
+
const budget3 = bodyBudget(ctx.format, p.eyebrow, p.headline, below, CHART_TOP, void 0, face);
|
|
4140
|
+
const H = Math.round(tall ? Math.min(budget3, width * TALL_ASPECT) : budget3);
|
|
3821
4141
|
const last = p.points[p.points.length - 1];
|
|
3822
4142
|
const valueW = textWidth(String(last?.y ?? ""), 40, 400, 0, false, face);
|
|
3823
4143
|
const labelW = textWidth(last?.x ?? "", 40, 400, 0, false, face);
|
|
@@ -4085,12 +4405,12 @@ function balance(label, k, face) {
|
|
|
4085
4405
|
if (cur.length > 0) lines.push(cur.join(" "));
|
|
4086
4406
|
return lines;
|
|
4087
4407
|
}
|
|
4088
|
-
function centre(
|
|
4089
|
-
const b =
|
|
4408
|
+
function centre(boxes3, i) {
|
|
4409
|
+
const b = boxes3[i];
|
|
4090
4410
|
return b ? b.x + b.w / 2 : 0;
|
|
4091
4411
|
}
|
|
4092
|
-
function loopLabelWidth(
|
|
4093
|
-
const mid = (centre(
|
|
4412
|
+
function loopLabelWidth(boxes3, from, to, stageW) {
|
|
4413
|
+
const mid = (centre(boxes3, from) + centre(boxes3, to)) / 2;
|
|
4094
4414
|
return Math.max(NOTE * 6, Math.min(stageW - 2 * M - 80, 2 * Math.min(mid, stageW - mid) - 40));
|
|
4095
4415
|
}
|
|
4096
4416
|
function widest(lines, face) {
|
|
@@ -4123,11 +4443,11 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4123
4443
|
let fit = solve(stages, capped, stageW, face);
|
|
4124
4444
|
if (!fit.ok && capped < width) fit = solve(stages, width, stageW, face);
|
|
4125
4445
|
const size3 = Math.max(MIN_FONT, Math.floor(fit.size));
|
|
4126
|
-
const
|
|
4127
|
-
const innerW = Math.max(size3, (
|
|
4446
|
+
const boxes3 = fit.boxes;
|
|
4447
|
+
const innerW = Math.max(size3, (boxes3[0]?.w ?? width) - 2 * PAD_X_EM * size3);
|
|
4128
4448
|
const labelLines = stages.map((s) => wrap(s.label, size3, innerW, 600, 0, face));
|
|
4129
4449
|
const noteLines = stages.map((s) => s.note ? wrap(s.note, NOTE, innerW, 400, 0, face) : []);
|
|
4130
|
-
const boxW =
|
|
4450
|
+
const boxW = boxes3[0]?.w ?? width;
|
|
4131
4451
|
const need = Math.max(
|
|
4132
4452
|
MIN_BOX_H,
|
|
4133
4453
|
Math.min(MAX_BOX_H, boxW * BOX_ASPECT),
|
|
@@ -4137,15 +4457,15 @@ function pipeLayout(stageW, stages, loop, face = "latin") {
|
|
|
4137
4457
|
return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
|
|
4138
4458
|
})
|
|
4139
4459
|
);
|
|
4140
|
-
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(
|
|
4460
|
+
const loopLines = loop ? wrap(loop.label, NOTE, loopLabelWidth(boxes3, loop.from, loop.to, stageW), 500, 0, face) : [];
|
|
4141
4461
|
const below = loop ? LOOP_TOP + LOOP_LABEL_TOP + loopLines.length * NOTE * NOTE_LH + LOOP_BOTTOM : M;
|
|
4142
4462
|
const boxH = need;
|
|
4143
4463
|
return {
|
|
4144
4464
|
size: size3,
|
|
4145
|
-
boxes,
|
|
4465
|
+
boxes: boxes3,
|
|
4146
4466
|
boxH,
|
|
4147
4467
|
boxX: M,
|
|
4148
|
-
boxW:
|
|
4468
|
+
boxW: boxes3[0]?.w ?? width,
|
|
4149
4469
|
vertical: false,
|
|
4150
4470
|
innerW,
|
|
4151
4471
|
labelLines,
|
|
@@ -4473,6 +4793,11 @@ var splitCompare = (beat, ctx) => {
|
|
|
4473
4793
|
if (fig.width <= 0 || fig.height <= 0) {
|
|
4474
4794
|
throw new Error(`split-compare ${beat.id}: figure "${fig.id}" has no usable dimensions`);
|
|
4475
4795
|
}
|
|
4796
|
+
if (fig.kind === "clip") {
|
|
4797
|
+
throw new Error(
|
|
4798
|
+
`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`
|
|
4799
|
+
);
|
|
4800
|
+
}
|
|
4476
4801
|
return fig;
|
|
4477
4802
|
});
|
|
4478
4803
|
sides.forEach((side, i) => {
|
|
@@ -4771,9 +5096,9 @@ var clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
|
4771
5096
|
function stackLayout(p, format, face = "latin") {
|
|
4772
5097
|
const stacked = solve2(p, format, false, face);
|
|
4773
5098
|
if (stacked.fits || !p.layers.some((l) => l.note)) return stacked;
|
|
4774
|
-
const
|
|
4775
|
-
if (
|
|
4776
|
-
return
|
|
5099
|
+
const inline2 = solve2(p, format, true, face);
|
|
5100
|
+
if (inline2.fits) return inline2;
|
|
5101
|
+
return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
|
|
4777
5102
|
}
|
|
4778
5103
|
function labelWeight(i, count) {
|
|
4779
5104
|
return i === count - 1 ? 700 : 600;
|
|
@@ -4782,7 +5107,7 @@ function floorFor(p, format) {
|
|
|
4782
5107
|
if (!p.tilt) return MIN_FONT;
|
|
4783
5108
|
return tiltedFloor({ ...DEFAULT_POSE, rotateX: p.tilt }, contentH(format), MIN_FONT);
|
|
4784
5109
|
}
|
|
4785
|
-
function solve2(p, format,
|
|
5110
|
+
function solve2(p, format, inline2, face) {
|
|
4786
5111
|
const width = contentW(format);
|
|
4787
5112
|
const boxH = contentH(format);
|
|
4788
5113
|
const floor = floorFor(p, format);
|
|
@@ -4791,15 +5116,15 @@ function solve2(p, format, inline, face) {
|
|
|
4791
5116
|
const riseMax = RISE_MAX[k];
|
|
4792
5117
|
const syMax = SY_MAX[k];
|
|
4793
5118
|
const tMax = T_MAX[k];
|
|
4794
|
-
const noteW = (l) =>
|
|
5119
|
+
const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
|
|
4795
5120
|
const want = Math.max(
|
|
4796
5121
|
...p.layers.map(
|
|
4797
5122
|
(l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
|
|
4798
5123
|
)
|
|
4799
5124
|
);
|
|
4800
|
-
const colCap =
|
|
5125
|
+
const colCap = inline2 ? width * 0.56 : width * 0.5;
|
|
4801
5126
|
const colW = clamp(Math.ceil(want) + 12, Math.min(520, width * 0.34), colCap);
|
|
4802
|
-
const wide = !
|
|
5127
|
+
const wide = !inline2 || Math.ceil(want) + 12 <= colCap;
|
|
4803
5128
|
const labelX = width - colW;
|
|
4804
5129
|
const labelRoom = Math.min(
|
|
4805
5130
|
...p.layers.map(
|
|
@@ -4814,14 +5139,14 @@ function solve2(p, format, inline, face) {
|
|
|
4814
5139
|
label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
|
|
4815
5140
|
// Inline notes stay on one line by contract — the schema calls a note "one
|
|
4816
5141
|
// short line" — and wrapping one would put its second line under the label.
|
|
4817
|
-
note: l.note === void 0 ? [] :
|
|
5142
|
+
note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
|
|
4818
5143
|
noteW: nw,
|
|
4819
5144
|
labelMaxW
|
|
4820
5145
|
};
|
|
4821
5146
|
});
|
|
4822
5147
|
const blockH = Math.max(
|
|
4823
5148
|
...lines.map(
|
|
4824
|
-
(l) =>
|
|
5149
|
+
(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
5150
|
)
|
|
4826
5151
|
);
|
|
4827
5152
|
const pad = Math.max(EDGE2, blockH / 2);
|
|
@@ -4843,7 +5168,7 @@ function solve2(p, format, inline, face) {
|
|
|
4843
5168
|
fits: room >= blockH + 10 && height <= free && wide,
|
|
4844
5169
|
floor,
|
|
4845
5170
|
wide,
|
|
4846
|
-
inline,
|
|
5171
|
+
inline: inline2,
|
|
4847
5172
|
width,
|
|
4848
5173
|
height,
|
|
4849
5174
|
avail,
|
|
@@ -5247,7 +5572,8 @@ function emitDeck(storyboard, source, format, runtimeJs, opts = {}) {
|
|
|
5247
5572
|
format,
|
|
5248
5573
|
laid.slides,
|
|
5249
5574
|
runtimeJs,
|
|
5250
|
-
narrationIsland(opts.narration, laid.spoken)
|
|
5575
|
+
narrationIsland(opts.narration, laid.spoken),
|
|
5576
|
+
videoIsland(laid.embeds)
|
|
5251
5577
|
)
|
|
5252
5578
|
};
|
|
5253
5579
|
}
|
|
@@ -5258,17 +5584,20 @@ function planCut(storyboard, source, format, opts = {}) {
|
|
|
5258
5584
|
const seconds = {};
|
|
5259
5585
|
const undrawable = /* @__PURE__ */ new Set();
|
|
5260
5586
|
floor.forEach((beat, i) => {
|
|
5261
|
-
const
|
|
5587
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5262
5588
|
let scene;
|
|
5263
5589
|
try {
|
|
5264
|
-
({ scene } = stageScene(
|
|
5590
|
+
({ scene } = stageScene(
|
|
5591
|
+
emitScene(beat, { source, format, theme, sid: `s${i + 1}`, start: 0 }),
|
|
5592
|
+
speed
|
|
5593
|
+
));
|
|
5265
5594
|
} catch (err) {
|
|
5266
5595
|
if (!opts.onBeatError) throw err;
|
|
5267
5596
|
opts.onBeatError(beat.id, err instanceof Error ? err : new Error(String(err)));
|
|
5268
5597
|
undrawable.add(beat.id);
|
|
5269
5598
|
return;
|
|
5270
5599
|
}
|
|
5271
|
-
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene,
|
|
5600
|
+
seconds[beat.id] = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5272
5601
|
});
|
|
5273
5602
|
if (floor.length > 0 && undrawable.size === floor.length) {
|
|
5274
5603
|
throw new Error(`every one of ${floor.length} beat(s) failed to draw \u2014 there is no deck`);
|
|
@@ -5301,31 +5630,29 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5301
5630
|
const scenes = [];
|
|
5302
5631
|
const slides = [];
|
|
5303
5632
|
const spoken = {};
|
|
5633
|
+
const embeds = {};
|
|
5304
5634
|
const entered = enteredParts(beats);
|
|
5635
|
+
let at = 0;
|
|
5305
5636
|
const cuts = beats.map((beat, i) => {
|
|
5306
5637
|
const sid = `s${i + 1}`;
|
|
5307
|
-
const ctx = { source, format, theme, sid };
|
|
5308
|
-
const
|
|
5638
|
+
const ctx = { source, format, theme, sid, start: rnd(at) };
|
|
5639
|
+
const segments2 = opts.narration?.beats[beat.id];
|
|
5309
5640
|
const { scene } = stageScene(emitScene(beat, ctx), speed);
|
|
5310
|
-
const seconds = beatSeconds(beat.seconds * speed, scene,
|
|
5641
|
+
const seconds = beatSeconds(beat.seconds * speed, scene, segments2);
|
|
5311
5642
|
const inside = entered[i];
|
|
5312
5643
|
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
|
-
};
|
|
5644
|
+
const duration = dive ? seconds + diveTail(dive) : seconds;
|
|
5645
|
+
const start = at;
|
|
5646
|
+
at += duration;
|
|
5647
|
+
return { beat, sid, scene, segments: segments2, inside, dive, duration, start };
|
|
5322
5648
|
});
|
|
5323
|
-
let start = 0;
|
|
5324
5649
|
let builds = false;
|
|
5325
5650
|
const plugins = /* @__PURE__ */ new Set();
|
|
5326
5651
|
cuts.forEach((cut2, i) => {
|
|
5327
|
-
const { beat, sid, dive, inside, duration } = cut2;
|
|
5652
|
+
const { beat, sid, dive, inside, duration, start } = cut2;
|
|
5328
5653
|
if (cut2.segments?.length) spoken[sid] = cut2.segments;
|
|
5654
|
+
const embed = playerEmbed(beat, source);
|
|
5655
|
+
if (embed) embeds[sid] = embed;
|
|
5329
5656
|
const next = cuts[i + 1];
|
|
5330
5657
|
const over = next ? rnd(Math.min(HANDOFF_SECONDS * speed, next.duration)) : 0;
|
|
5331
5658
|
let scene = cut2.scene;
|
|
@@ -5355,7 +5682,6 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5355
5682
|
notes: beat.narration ?? beat.intent,
|
|
5356
5683
|
holds: scene.holds
|
|
5357
5684
|
});
|
|
5358
|
-
start += duration;
|
|
5359
5685
|
});
|
|
5360
5686
|
return {
|
|
5361
5687
|
family,
|
|
@@ -5367,7 +5693,11 @@ function layout(storyboard, source, format, opts = {}) {
|
|
|
5367
5693
|
scenes,
|
|
5368
5694
|
slides,
|
|
5369
5695
|
spoken,
|
|
5370
|
-
|
|
5696
|
+
embeds,
|
|
5697
|
+
// The clock after the last scene: the sum the map above finished with, which
|
|
5698
|
+
// is the number the second pass used to arrive at by re-adding the same
|
|
5699
|
+
// durations in the same order.
|
|
5700
|
+
total: at,
|
|
5371
5701
|
cut,
|
|
5372
5702
|
builds,
|
|
5373
5703
|
plugins
|
|
@@ -5418,23 +5748,23 @@ function openSeconds(scene) {
|
|
|
5418
5748
|
const first = scene.holds.filter((h) => Number.isFinite(h) && h > 0).sort((a, b) => a - b)[0];
|
|
5419
5749
|
return rnd(first ?? 0);
|
|
5420
5750
|
}
|
|
5421
|
-
function beatSeconds(authored, scene,
|
|
5422
|
-
if (!
|
|
5751
|
+
function beatSeconds(authored, scene, segments2) {
|
|
5752
|
+
if (!segments2?.length) return authored;
|
|
5423
5753
|
const lastHold = scene.holds.reduce((a, b) => Math.max(a, b), 0);
|
|
5424
5754
|
const usable = [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
5425
5755
|
(a, b) => a - b
|
|
5426
5756
|
);
|
|
5427
|
-
const ends = speechPlan(openSeconds(scene), usable,
|
|
5757
|
+
const ends = speechPlan(openSeconds(scene), usable, segments2).end;
|
|
5428
5758
|
return Math.max(authored, lastHold + SETTLE_SECONDS, ends + SETTLE_SECONDS);
|
|
5429
5759
|
}
|
|
5430
5760
|
function stageScene(scene, speed) {
|
|
5431
5761
|
const paced = pace(scene, speed);
|
|
5432
5762
|
return { scene: paced, open: openSeconds(paced) };
|
|
5433
5763
|
}
|
|
5434
|
-
function speechPlan(open, holds,
|
|
5764
|
+
function speechPlan(open, holds, segments2) {
|
|
5435
5765
|
const starts = [];
|
|
5436
5766
|
let at = open;
|
|
5437
|
-
for (const [i, segment] of
|
|
5767
|
+
for (const [i, segment] of segments2.entries()) {
|
|
5438
5768
|
const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
|
|
5439
5769
|
if (i > 0) at = Math.max(at, hold);
|
|
5440
5770
|
starts.push(at);
|
|
@@ -5526,9 +5856,9 @@ function narrationIsland(narration, scenes) {
|
|
|
5526
5856
|
voice: narration.voice,
|
|
5527
5857
|
dir: narration.dir,
|
|
5528
5858
|
scenes: Object.fromEntries(
|
|
5529
|
-
Object.entries(scenes).map(([sid,
|
|
5859
|
+
Object.entries(scenes).map(([sid, segments2]) => [
|
|
5530
5860
|
sid,
|
|
5531
|
-
|
|
5861
|
+
segments2.map((s) => ({ stop: s.stop, audio: s.audio, seconds: s.seconds, cues: s.cues }))
|
|
5532
5862
|
])
|
|
5533
5863
|
)
|
|
5534
5864
|
};
|
|
@@ -5538,7 +5868,22 @@ function narrationIsland(narration, scenes) {
|
|
|
5538
5868
|
${json}
|
|
5539
5869
|
</script>`;
|
|
5540
5870
|
}
|
|
5541
|
-
function
|
|
5871
|
+
function playerEmbed(beat, source) {
|
|
5872
|
+
if (beat.archetype !== "claim-figure") return void 0;
|
|
5873
|
+
const fig = source.figures.find((f) => f.id === beat.params.figureId);
|
|
5874
|
+
if (fig?.kind !== "clip" || fig.href === void 0) return void 0;
|
|
5875
|
+
const url2 = embedUrl(fig.href);
|
|
5876
|
+
return url2 === void 0 ? void 0 : { url: url2, title: fig.caption };
|
|
5877
|
+
}
|
|
5878
|
+
function videoIsland(clips) {
|
|
5879
|
+
if (Object.keys(clips).length === 0) return "";
|
|
5880
|
+
const json = JSON.stringify({ scenes: clips }, null, 2).replace(/</g, "\\u003c");
|
|
5881
|
+
return `
|
|
5882
|
+
<script type="application/decksmith-video+json">
|
|
5883
|
+
${json}
|
|
5884
|
+
</script>`;
|
|
5885
|
+
}
|
|
5886
|
+
function emitDeckPage(storyboard, format, slides, runtimeJs, narration, video) {
|
|
5542
5887
|
return `<!doctype html>
|
|
5543
5888
|
<html lang="${esc(storyboard.lang)}">
|
|
5544
5889
|
<head>
|
|
@@ -5557,7 +5902,7 @@ function emitDeckPage(storyboard, format, slides, runtimeJs, narration) {
|
|
|
5557
5902
|
width="${format.width}"
|
|
5558
5903
|
height="${format.height}"
|
|
5559
5904
|
></hyperframes-player>
|
|
5560
|
-
${emitIsland(slides)}${narration}
|
|
5905
|
+
${emitIsland(slides)}${narration}${video}
|
|
5561
5906
|
<script>
|
|
5562
5907
|
${closeSafe(runtimeJs)}
|
|
5563
5908
|
</script>
|
|
@@ -5628,91 +5973,621 @@ function rnd(n3) {
|
|
|
5628
5973
|
}
|
|
5629
5974
|
|
|
5630
5975
|
// src/images/illustrate.ts
|
|
5631
|
-
import { createHash as
|
|
5632
|
-
import { mkdir as mkdir3, readFile as
|
|
5976
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
5977
|
+
import { mkdir as mkdir3, readFile as readFile6, rename, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
5633
5978
|
import { join as join5 } from "node:path";
|
|
5634
5979
|
|
|
5635
5980
|
// src/source/assets.ts
|
|
5636
|
-
import { createHash as
|
|
5637
|
-
import { mkdir as mkdir2, readFile as
|
|
5638
|
-
import { extname, join as join2 } from "node:path";
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5981
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5982
|
+
import { mkdir as mkdir2, readdir, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
5983
|
+
import { extname as extname2, join as join2 } from "node:path";
|
|
5984
|
+
import { z as z2 } from "zod";
|
|
5985
|
+
|
|
5986
|
+
// src/net/fetch.ts
|
|
5987
|
+
import { lookup as resolveHost } from "node:dns/promises";
|
|
5988
|
+
import { request as httpRequest } from "node:http";
|
|
5989
|
+
import { request as httpsRequest } from "node:https";
|
|
5990
|
+
import { isIP } from "node:net";
|
|
5991
|
+
import { brotliDecompressSync, gunzipSync } from "node:zlib";
|
|
5992
|
+
|
|
5993
|
+
// src/version.ts
|
|
5994
|
+
import { createRequire } from "node:module";
|
|
5995
|
+
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
5996
|
+
|
|
5997
|
+
// src/net/fetch.ts
|
|
5998
|
+
var BLOCKED_V4 = [
|
|
5999
|
+
// "This network". Already in the original list as /^0\./, which is 0.0.0.0/8.
|
|
6000
|
+
{ re: /^0\./, why: "the unspecified network (0.0.0.0/8)" },
|
|
6001
|
+
{ re: /^10\./, why: "a private address (10.0.0.0/8)" },
|
|
6002
|
+
// NEW: carrier-grade NAT. A mobile or datacentre network hands these out, and
|
|
6003
|
+
// on a host that has one, the whole /10 is the neighbourhood.
|
|
6004
|
+
{
|
|
6005
|
+
re: /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
|
6006
|
+
why: "carrier-grade NAT space (100.64.0.0/10)"
|
|
6007
|
+
},
|
|
6008
|
+
{ re: /^127\./, why: "loopback (127.0.0.0/8)" },
|
|
6009
|
+
{ re: /^169\.254\./, why: "link-local, where the cloud metadata service lives (169.254.0.0/16)" },
|
|
6010
|
+
{ re: /^172\.(1[6-9]|2\d|3[01])\./, why: "a private address (172.16.0.0/12)" },
|
|
6011
|
+
// NEW: IETF protocol assignments — 192.0.0.8, the NAT64 well-known prefix and
|
|
6012
|
+
// friends. Not routable on the public internet, so a URL pointing here is
|
|
6013
|
+
// pointing at something local.
|
|
6014
|
+
{ re: /^192\.0\.0\./, why: "IETF protocol assignment space (192.0.0.0/24)" },
|
|
6015
|
+
{ re: /^192\.168\./, why: "a private address (192.168.0.0/16)" },
|
|
6016
|
+
// NEW: benchmarking. Reserved for test equipment, and some networks route it
|
|
6017
|
+
// internally precisely because it will never collide with anything real.
|
|
6018
|
+
{ re: /^198\.1[89]\./, why: "benchmarking space (198.18.0.0/15)" }
|
|
6019
|
+
];
|
|
6020
|
+
var USER_AGENT = `DeckSmith/${VERSION} (+https://github.com/ca1773130n/DeckSmith)`;
|
|
6021
|
+
function isBlockedAddress(ip) {
|
|
6022
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6023
|
+
const kind = isIP(bare);
|
|
6024
|
+
if (kind === 0) return `not an IP address (${ip})`;
|
|
6025
|
+
if (kind === 4) {
|
|
6026
|
+
for (const { re, why: why3 } of BLOCKED_V4) if (re.test(bare)) return why3;
|
|
6027
|
+
return null;
|
|
5663
6028
|
}
|
|
5664
|
-
|
|
5665
|
-
return
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
6029
|
+
const groups = expandV6(bare);
|
|
6030
|
+
if (groups === null) return `not an IP address (${ip})`;
|
|
6031
|
+
if (groups.every((n3) => n3 === 0)) return "the unspecified address (::)";
|
|
6032
|
+
if (groups.slice(0, 7).every((n3) => n3 === 0) && groups[7] === 1) return "IPv6 loopback (::1)";
|
|
6033
|
+
const embedded = groups.slice(0, 5).every((n3) => n3 === 0) && (groups[5] === 65535 || groups[5] === 0);
|
|
6034
|
+
if (embedded) {
|
|
6035
|
+
const hi = groups[6] ?? 0;
|
|
6036
|
+
const lo = groups[7] ?? 0;
|
|
6037
|
+
return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
|
|
6038
|
+
}
|
|
6039
|
+
const top = groups[0] ?? 0;
|
|
6040
|
+
if ((top & 65472) === 65152) return "IPv6 link-local (fe80::/10)";
|
|
6041
|
+
if ((top & 65024) === 64512) return "IPv6 unique-local (fc00::/7)";
|
|
6042
|
+
return null;
|
|
5674
6043
|
}
|
|
5675
|
-
function
|
|
5676
|
-
let
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
6044
|
+
function expandV6(v6) {
|
|
6045
|
+
let text2 = v6;
|
|
6046
|
+
const quad = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(text2);
|
|
6047
|
+
if (quad?.[1]) {
|
|
6048
|
+
const [a = 0, b = 0, c = 0, d = 0] = quad[1].split(".").map(Number);
|
|
6049
|
+
const head = text2.slice(0, text2.length - quad[1].length);
|
|
6050
|
+
text2 = `${head}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
6051
|
+
}
|
|
6052
|
+
const halves = text2.split("::");
|
|
6053
|
+
if (halves.length > 2) return null;
|
|
6054
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
6055
|
+
const right = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
6056
|
+
const fill = 8 - left.length - (right?.length ?? 0);
|
|
6057
|
+
if (right !== null && fill < 0) return null;
|
|
6058
|
+
const parts = right === null ? left : [...left, ...new Array(fill).fill("0"), ...right];
|
|
6059
|
+
if (parts.length !== 8) return null;
|
|
6060
|
+
const groups = parts.map((p) => Number.parseInt(p, 16));
|
|
6061
|
+
return groups.every((n3) => Number.isInteger(n3) && n3 >= 0 && n3 <= 65535) ? groups : null;
|
|
6062
|
+
}
|
|
6063
|
+
function isLoopback(ip) {
|
|
6064
|
+
const bare = ip.trim().replace(/^\[|\]$/g, "").toLowerCase();
|
|
6065
|
+
return /^127\./.test(bare) || bare === "::1" || /^::ffff:127\./.test(bare);
|
|
6066
|
+
}
|
|
6067
|
+
var MAX_REDIRECTS = 5;
|
|
6068
|
+
var WEB_PORTS = /* @__PURE__ */ new Set([80, 443]);
|
|
6069
|
+
var REDIRECTS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
6070
|
+
var CREDENTIALS = ["authorization", "cookie"];
|
|
6071
|
+
async function fetchGuarded(url2, opts) {
|
|
6072
|
+
const signal = AbortSignal.timeout(opts.timeoutMs);
|
|
6073
|
+
let target;
|
|
6074
|
+
try {
|
|
6075
|
+
target = new URL(url2);
|
|
6076
|
+
} catch {
|
|
6077
|
+
throw new Error(`refusing ${url2}: not a URL. Give an absolute http:// or https:// URL.`);
|
|
6078
|
+
}
|
|
6079
|
+
let headers = lowercased(opts.headers);
|
|
6080
|
+
for (let hop = 0; ; hop++) {
|
|
6081
|
+
if (hop > MAX_REDIRECTS) {
|
|
6082
|
+
throw new Error(
|
|
6083
|
+
`refusing ${url2}: more than ${MAX_REDIRECTS} redirects, last to ${target.href}. Link the final URL directly.`
|
|
6084
|
+
);
|
|
5681
6085
|
}
|
|
5682
|
-
const
|
|
5683
|
-
|
|
5684
|
-
if (
|
|
5685
|
-
|
|
6086
|
+
const res = await send(url2, target, headers, signal, opts);
|
|
6087
|
+
const status = res.statusCode ?? 0;
|
|
6088
|
+
if (REDIRECTS.has(status)) {
|
|
6089
|
+
const location = res.headers.location;
|
|
6090
|
+
res.destroy();
|
|
6091
|
+
if (location === void 0) {
|
|
6092
|
+
throw new Error(`refusing ${url2}: HTTP ${status} from ${target.href} with no Location.`);
|
|
6093
|
+
}
|
|
6094
|
+
let next;
|
|
6095
|
+
try {
|
|
6096
|
+
next = new URL(location, target);
|
|
6097
|
+
} catch {
|
|
6098
|
+
throw new Error(
|
|
6099
|
+
`refusing ${url2}: HTTP ${status} to an unparseable Location (${location}).`
|
|
6100
|
+
);
|
|
6101
|
+
}
|
|
6102
|
+
const sameOrigin = next.protocol === target.protocol && next.hostname === target.hostname && next.port === target.port;
|
|
6103
|
+
if (!sameOrigin) headers = withoutCredentials(headers);
|
|
6104
|
+
target = next;
|
|
5686
6105
|
continue;
|
|
5687
6106
|
}
|
|
5688
|
-
if (
|
|
5689
|
-
|
|
5690
|
-
|
|
6107
|
+
if (status < 200 || status >= 300) {
|
|
6108
|
+
res.destroy();
|
|
6109
|
+
throw new Error(`${target.href}: HTTP ${status}`);
|
|
5691
6110
|
}
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
6111
|
+
const contentType = (res.headers["content-type"] ?? "").trim();
|
|
6112
|
+
const media = (contentType.split(";")[0] ?? "").trim().toLowerCase();
|
|
6113
|
+
if (opts.accept && media.match(opts.accept) === null) {
|
|
6114
|
+
res.destroy();
|
|
6115
|
+
throw new Error(
|
|
6116
|
+
`refusing ${url2}: ${target.href} served ${media || "no Content-Type"}, which does not match ${opts.accept}. Link the file itself, not a page about it.`
|
|
6117
|
+
);
|
|
6118
|
+
}
|
|
6119
|
+
const raw2 = await readCapped(res, opts.maxBytes, url2, signal, opts.timeoutMs);
|
|
6120
|
+
const bytes = decode(raw2, res.headers["content-encoding"], opts.maxBytes, url2);
|
|
6121
|
+
return { bytes, contentType, url: target.href };
|
|
5695
6122
|
}
|
|
5696
|
-
throw new Error("JPEG has no SOF segment");
|
|
5697
6123
|
}
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
6124
|
+
async function send(url2, target, headers, signal, opts) {
|
|
6125
|
+
const secure = target.protocol === "https:";
|
|
6126
|
+
if (!secure && target.protocol !== "http:") {
|
|
6127
|
+
throw new Error(
|
|
6128
|
+
`refusing ${url2}: ${target.protocol}// is not http or https (${target.href}). Only web URLs are followed.`
|
|
6129
|
+
);
|
|
6130
|
+
}
|
|
6131
|
+
const port = Number(target.port || (secure ? 443 : 80));
|
|
6132
|
+
if (!WEB_PORTS.has(port) && opts.allowLoopback !== true) {
|
|
6133
|
+
throw new Error(
|
|
6134
|
+
`refusing ${url2}: port ${port} on ${target.hostname} \u2014 only 80 and 443 are fetched. A URL naming another port is naming a service, not a page.`
|
|
6135
|
+
);
|
|
6136
|
+
}
|
|
6137
|
+
const host = target.hostname.replace(/^\[|\]$/g, "");
|
|
6138
|
+
let addresses;
|
|
6139
|
+
try {
|
|
6140
|
+
addresses = await Promise.race([resolveHost(host, { all: true }), rejectsOnAbort(signal)]);
|
|
6141
|
+
} catch {
|
|
6142
|
+
if (signal.aborted) throw timedOut(url2, opts.timeoutMs);
|
|
6143
|
+
throw new Error(`refusing ${url2}: ${host} does not resolve.`);
|
|
6144
|
+
}
|
|
6145
|
+
const pinned = addresses[0];
|
|
6146
|
+
if (pinned === void 0) throw new Error(`refusing ${url2}: ${host} does not resolve.`);
|
|
6147
|
+
for (const { address } of addresses) {
|
|
6148
|
+
const why3 = isBlockedAddress(address);
|
|
6149
|
+
if (why3 !== null && !(opts.allowLoopback === true && isLoopback(address))) {
|
|
6150
|
+
throw new Error(
|
|
6151
|
+
`refusing ${url2}: ${host} resolves to ${address}, which is ${why3}. Host the file somewhere this server can reach from the public internet.`
|
|
6152
|
+
);
|
|
6153
|
+
}
|
|
6154
|
+
}
|
|
6155
|
+
const options = {
|
|
6156
|
+
hostname: host,
|
|
6157
|
+
port,
|
|
6158
|
+
path: `${target.pathname}${target.search}`,
|
|
6159
|
+
method: "GET",
|
|
6160
|
+
// IDENTIFY OURSELVES, AND ACCEPT HTML.
|
|
6161
|
+
//
|
|
6162
|
+
// Measured against the first real page this was ever pointed at: Wikipedia
|
|
6163
|
+
// answers a request with no `user-agent` with a bare 403, and so do a great
|
|
6164
|
+
// many sites behind a CDN. A fetcher that will not say who it is looks
|
|
6165
|
+
// exactly like a scraper worth blocking, and the failure arrives as an
|
|
6166
|
+
// HTTP status with nothing in it to explain itself. Both headers are
|
|
6167
|
+
// DEFAULTS rather than overrides — a caller that sets either wins, because
|
|
6168
|
+
// the spread below them is the caller's.
|
|
6169
|
+
headers: {
|
|
6170
|
+
"user-agent": USER_AGENT,
|
|
6171
|
+
accept: "text/html,application/xhtml+xml,image/*;q=0.8,*/*;q=0.5",
|
|
6172
|
+
...headers,
|
|
6173
|
+
"accept-encoding": "gzip, br"
|
|
6174
|
+
},
|
|
6175
|
+
signal,
|
|
6176
|
+
// THE PIN. Without this line every check above is advisory: `dns.lookup`
|
|
6177
|
+
// would run a second time inside the connect and could answer differently.
|
|
6178
|
+
lookup: pin(pinned.address, pinned.family),
|
|
6179
|
+
// No connection pool. This is a one-shot against a stranger's host, and a
|
|
6180
|
+
// pooled socket outlives the call that validated it — nothing should be able
|
|
6181
|
+
// to inherit a connection this module opened.
|
|
6182
|
+
agent: false,
|
|
6183
|
+
// Say out loud which name the certificate has to match. Node derives this
|
|
6184
|
+
// from `hostname` today, but the pin means the socket is opened to a bare
|
|
6185
|
+
// address, and a future refactor that stops setting `hostname` would turn
|
|
6186
|
+
// verification off silently rather than fail.
|
|
6187
|
+
...secure && isIP(host) === 0 ? { servername: host } : {}
|
|
6188
|
+
};
|
|
6189
|
+
try {
|
|
6190
|
+
return await new Promise((resolve6, reject) => {
|
|
6191
|
+
const req = (secure ? httpsRequest : httpRequest)(options, resolve6);
|
|
6192
|
+
req.on("error", reject);
|
|
6193
|
+
req.end();
|
|
6194
|
+
});
|
|
6195
|
+
} catch (err) {
|
|
6196
|
+
if (signal.aborted) throw timedOut(url2, opts.timeoutMs);
|
|
6197
|
+
throw new Error(`refusing ${url2}: ${target.href} could not be reached (${message(err)}).`);
|
|
6198
|
+
}
|
|
6199
|
+
}
|
|
6200
|
+
function pin(address, family) {
|
|
6201
|
+
return (_hostname, options, callback) => {
|
|
6202
|
+
if (options.all) callback(null, [{ address, family }]);
|
|
6203
|
+
else callback(null, address, family);
|
|
6204
|
+
};
|
|
6205
|
+
}
|
|
6206
|
+
async function readCapped(res, maxBytes, url2, signal, timeoutMs) {
|
|
6207
|
+
const chunks = [];
|
|
6208
|
+
let total = 0;
|
|
6209
|
+
let oversize = false;
|
|
6210
|
+
try {
|
|
6211
|
+
for await (const chunk of res) {
|
|
6212
|
+
total += chunk.length;
|
|
6213
|
+
if (total > maxBytes) {
|
|
6214
|
+
oversize = true;
|
|
6215
|
+
res.destroy();
|
|
6216
|
+
break;
|
|
6217
|
+
}
|
|
6218
|
+
chunks.push(chunk);
|
|
6219
|
+
}
|
|
6220
|
+
} catch (err) {
|
|
6221
|
+
if (signal.aborted) throw timedOut(url2, timeoutMs);
|
|
6222
|
+
throw new Error(`refusing ${url2}: the body stopped arriving (${message(err)}).`);
|
|
6223
|
+
}
|
|
6224
|
+
if (oversize) {
|
|
6225
|
+
throw new Error(
|
|
6226
|
+
`refusing ${url2}: body is larger than ${maxBytes} bytes. Link a smaller file, or raise maxBytes if this one is expected.`
|
|
6227
|
+
);
|
|
6228
|
+
}
|
|
6229
|
+
return Buffer.concat(chunks);
|
|
6230
|
+
}
|
|
6231
|
+
function decode(raw2, header, maxBytes, url2) {
|
|
6232
|
+
const encoding = (header ?? "").trim().toLowerCase();
|
|
6233
|
+
if (encoding === "" || encoding === "identity") return raw2;
|
|
6234
|
+
try {
|
|
6235
|
+
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
6236
|
+
return gunzipSync(raw2, { maxOutputLength: maxBytes });
|
|
6237
|
+
}
|
|
6238
|
+
if (encoding === "br") return brotliDecompressSync(raw2, { maxOutputLength: maxBytes });
|
|
6239
|
+
} catch (err) {
|
|
6240
|
+
throw new Error(
|
|
6241
|
+
`refusing ${url2}: ${encoding} body does not decompress within ${maxBytes} bytes (${message(err)}). Serve it uncompressed, or raise maxBytes.`
|
|
6242
|
+
);
|
|
6243
|
+
}
|
|
6244
|
+
throw new Error(
|
|
6245
|
+
`refusing ${url2}: Content-Encoding "${encoding}" was never offered \u2014 this request asked for gzip or br. Serve one of those, or no encoding.`
|
|
6246
|
+
);
|
|
6247
|
+
}
|
|
6248
|
+
function lowercased(headers) {
|
|
6249
|
+
const out = {};
|
|
6250
|
+
for (const [name, value] of Object.entries(headers ?? {})) out[name.toLowerCase()] = value;
|
|
6251
|
+
return out;
|
|
6252
|
+
}
|
|
6253
|
+
function withoutCredentials(headers) {
|
|
6254
|
+
const out = { ...headers };
|
|
6255
|
+
for (const name of CREDENTIALS) delete out[name];
|
|
6256
|
+
return out;
|
|
6257
|
+
}
|
|
6258
|
+
function rejectsOnAbort(signal) {
|
|
6259
|
+
return new Promise((_, reject) => {
|
|
6260
|
+
if (signal.aborted) reject(signal.reason);
|
|
6261
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
6262
|
+
});
|
|
6263
|
+
}
|
|
6264
|
+
function timedOut(url2, timeoutMs) {
|
|
6265
|
+
return new Error(
|
|
6266
|
+
`refusing ${url2}: nothing completed within ${timeoutMs}ms. Raise timeoutMs, or use a URL that answers faster.`
|
|
6267
|
+
);
|
|
6268
|
+
}
|
|
6269
|
+
function message(err) {
|
|
6270
|
+
return err instanceof Error ? err.message : String(err);
|
|
6271
|
+
}
|
|
6272
|
+
|
|
6273
|
+
// src/source/assets.ts
|
|
6274
|
+
async function fetchFigures(source, dir, warnings) {
|
|
6275
|
+
const drops = warnings ?? [];
|
|
6276
|
+
await mkdir2(dir, { recursive: true });
|
|
6277
|
+
const cached = await readdir(dir).catch(() => []);
|
|
6278
|
+
const figures = [];
|
|
6279
|
+
for (const figure of source.figures) {
|
|
6280
|
+
if (figure.kind === "clip") {
|
|
6281
|
+
figures.push(figure);
|
|
6282
|
+
continue;
|
|
6283
|
+
}
|
|
6284
|
+
try {
|
|
6285
|
+
figures.push(figureSchema.parse(await localize(figure, dir, cached)));
|
|
6286
|
+
} catch (err) {
|
|
6287
|
+
drops.push(`figure ${figure.id} was left out: ${reason(err)}`);
|
|
6288
|
+
}
|
|
6289
|
+
}
|
|
6290
|
+
if (!warnings) for (const drop of drops) console.warn(`decksmith: ${drop}`);
|
|
6291
|
+
const parsed = sourceSchema.safeParse({ ...source, figures });
|
|
6292
|
+
if (!parsed.success) {
|
|
6293
|
+
throw new Error(
|
|
6294
|
+
`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.`
|
|
6295
|
+
);
|
|
6296
|
+
}
|
|
6297
|
+
return parsed.data;
|
|
6298
|
+
}
|
|
6299
|
+
async function localize(figure, dir, cached) {
|
|
6300
|
+
const stem = assetStem(figure.id, figure.src);
|
|
6301
|
+
const hit = cached.find((name) => name.startsWith(`${stem}.`));
|
|
6302
|
+
const bytes = hit ? await readFile3(join2(dir, hit)) : await load(figure.src);
|
|
6303
|
+
const size3 = imageSize(bytes);
|
|
6304
|
+
if (hit) return { ...figure, src: hit, ...size3 };
|
|
6305
|
+
const src = `${stem}${assetExt(bytes, figure.src)}`;
|
|
6306
|
+
await writeFile2(join2(dir, src), bytes);
|
|
6307
|
+
cached.push(src);
|
|
6308
|
+
return { ...figure, src, ...size3 };
|
|
6309
|
+
}
|
|
6310
|
+
function assetStem(id2, src) {
|
|
6311
|
+
const readable = id2.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+/, "") || "figure";
|
|
6312
|
+
return `${readable}-${createHash3("sha256").update(src).digest("hex").slice(0, 8)}`;
|
|
6313
|
+
}
|
|
6314
|
+
var EXT = {
|
|
6315
|
+
png: ".png",
|
|
6316
|
+
jpeg: ".jpg",
|
|
6317
|
+
gif: ".gif",
|
|
6318
|
+
webp: ".webp",
|
|
6319
|
+
avif: ".avif",
|
|
6320
|
+
svg: ".svg"
|
|
6321
|
+
};
|
|
6322
|
+
function assetExt(bytes, src) {
|
|
6323
|
+
const format = sniffFormat(bytes);
|
|
6324
|
+
if (format) return EXT[format];
|
|
6325
|
+
const fromUrl = extname2(new URL(src, "file:///").pathname).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
6326
|
+
return fromUrl || ".img";
|
|
6327
|
+
}
|
|
6328
|
+
var FIGURE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6329
|
+
var FIGURE_TIMEOUT_MS = 2e4;
|
|
6330
|
+
async function load(src) {
|
|
6331
|
+
if (!/^https?:/i.test(src)) return readFile3(src);
|
|
6332
|
+
const got = await fetchGuarded(src, {
|
|
6333
|
+
maxBytes: FIGURE_MAX_BYTES,
|
|
6334
|
+
timeoutMs: FIGURE_TIMEOUT_MS
|
|
6335
|
+
});
|
|
6336
|
+
return got.bytes;
|
|
6337
|
+
}
|
|
6338
|
+
function reason(err) {
|
|
6339
|
+
if (err instanceof z2.ZodError)
|
|
6340
|
+
return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
6341
|
+
return err instanceof Error ? err.message : String(err);
|
|
6342
|
+
}
|
|
6343
|
+
var SVG_WINDOW = 64 * 1024;
|
|
6344
|
+
function sniffFormat(b) {
|
|
6345
|
+
if (b.length >= 8 && b.readUInt32BE(0) === 2303741511 && b.readUInt32BE(4) === 218765834)
|
|
6346
|
+
return "png";
|
|
6347
|
+
if (b.length >= 6 && b.toString("latin1", 0, 4) === "GIF8") return "gif";
|
|
6348
|
+
if (b.length >= 2 && b.readUInt16BE(0) === 65496) return "jpeg";
|
|
6349
|
+
if (b.length >= 12 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP")
|
|
6350
|
+
return "webp";
|
|
6351
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp" && isAvifBrand(b)) return "avif";
|
|
6352
|
+
if (isSvg(b)) return "svg";
|
|
6353
|
+
return void 0;
|
|
6354
|
+
}
|
|
6355
|
+
function isAvifBrand(b) {
|
|
6356
|
+
const declared = b.readUInt32BE(0);
|
|
6357
|
+
const end = Math.min(declared >= 16 ? declared : b.length, b.length);
|
|
6358
|
+
const brand = (at) => b.toString("latin1", at, at + 4);
|
|
6359
|
+
if (brand(8) === "avif" || brand(8) === "avis") return true;
|
|
6360
|
+
for (let i = 16; i + 4 <= end; i += 4)
|
|
6361
|
+
if (brand(i) === "avif" || brand(i) === "avis") return true;
|
|
6362
|
+
return false;
|
|
6363
|
+
}
|
|
6364
|
+
function isSvg(b) {
|
|
6365
|
+
const text2 = b.toString("utf8", 0, Math.min(b.length, SVG_WINDOW)).replace(/^\uFEFF/, "");
|
|
6366
|
+
let i = 0;
|
|
6367
|
+
for (; ; ) {
|
|
6368
|
+
while (i < text2.length && /\s/.test(text2.charAt(i))) i++;
|
|
6369
|
+
const comment = text2.startsWith("<!--", i);
|
|
6370
|
+
if (text2.startsWith("<?", i) || comment || /^<!doctype\s+svg\b/i.test(text2.slice(i, i + 20))) {
|
|
6371
|
+
const end = comment ? text2.indexOf("-->", i) + 3 : text2.indexOf(">", i) + 1;
|
|
6372
|
+
if (end <= 0) return false;
|
|
6373
|
+
i = end;
|
|
6374
|
+
continue;
|
|
6375
|
+
}
|
|
6376
|
+
return /^<svg[\s/>]/i.test(text2.slice(i, i + 5));
|
|
6377
|
+
}
|
|
6378
|
+
}
|
|
6379
|
+
function imageSize(b) {
|
|
6380
|
+
const format = sniffFormat(b);
|
|
6381
|
+
const raw2 = measure2(b, format);
|
|
6382
|
+
const width = Math.round(raw2.width);
|
|
6383
|
+
const height = Math.round(raw2.height);
|
|
6384
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
|
|
6385
|
+
throw new Error(`${format ?? "image"} header claims ${raw2.width}x${raw2.height}, not a size`);
|
|
6386
|
+
return { width, height };
|
|
6387
|
+
}
|
|
6388
|
+
function measure2(b, format) {
|
|
6389
|
+
switch (format) {
|
|
6390
|
+
case "png":
|
|
6391
|
+
return pngSize(b);
|
|
6392
|
+
case "gif":
|
|
6393
|
+
return gifSize(b);
|
|
6394
|
+
case "jpeg":
|
|
6395
|
+
return jpegSize(b);
|
|
6396
|
+
case "webp":
|
|
6397
|
+
return webpSize(b);
|
|
6398
|
+
case "avif":
|
|
6399
|
+
return avifSize(b);
|
|
6400
|
+
case "svg":
|
|
6401
|
+
return svgFigureSize(b);
|
|
6402
|
+
default:
|
|
6403
|
+
throw new Error(
|
|
6404
|
+
`unrecognised image header (expected PNG, JPEG, GIF, WebP, AVIF or SVG; got ${describe(b)})`
|
|
6405
|
+
);
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
function describe(b) {
|
|
6409
|
+
if (b.length === 0) return "an empty file";
|
|
6410
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp")
|
|
6411
|
+
return `an ISO-BMFF file branded "${b.toString("latin1", 8, 12)}" \u2014 HEIC and its relatives are not drawn by the renderer`;
|
|
6412
|
+
const head = b.toString("latin1", 0, Math.min(b.length, 64));
|
|
6413
|
+
if (/^\s*<(?:!doctype\s+html|html|head|body)\b/i.test(head))
|
|
6414
|
+
return "an HTML page \u2014 the URL answered with a page, not a picture";
|
|
6415
|
+
if (head.startsWith("%PDF")) return "a PDF";
|
|
6416
|
+
const hex = [...b.subarray(0, 8)].map((x) => x.toString(16).padStart(2, "0")).join(" ");
|
|
6417
|
+
return `${b.length} bytes beginning ${hex}`;
|
|
6418
|
+
}
|
|
6419
|
+
function pngSize(b) {
|
|
6420
|
+
if (b.length < 24) throw new Error(`PNG is truncated: ${b.length} bytes, IHDR ends at 24`);
|
|
6421
|
+
if (b.toString("latin1", 12, 16) !== "IHDR") throw new Error("PNG does not open with IHDR");
|
|
6422
|
+
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
|
6423
|
+
}
|
|
6424
|
+
function gifSize(b) {
|
|
6425
|
+
if (b.length < 10)
|
|
6426
|
+
throw new Error(`GIF is truncated: ${b.length} bytes, the screen descriptor ends at 10`);
|
|
6427
|
+
return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
|
|
6428
|
+
}
|
|
6429
|
+
function jpegSize(b) {
|
|
6430
|
+
let i = 2;
|
|
6431
|
+
while (i + 9 < b.length) {
|
|
6432
|
+
if (b[i] !== 255) {
|
|
6433
|
+
i++;
|
|
6434
|
+
continue;
|
|
6435
|
+
}
|
|
6436
|
+
const marker = b[i + 1];
|
|
6437
|
+
if (marker === void 0) break;
|
|
6438
|
+
if (marker === 255) {
|
|
6439
|
+
i++;
|
|
6440
|
+
continue;
|
|
6441
|
+
}
|
|
6442
|
+
if (marker === 1 || marker >= 208 && marker <= 217) {
|
|
6443
|
+
i += 2;
|
|
6444
|
+
continue;
|
|
6445
|
+
}
|
|
6446
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204)
|
|
6447
|
+
return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
|
|
6448
|
+
i += 2 + b.readUInt16BE(i + 2);
|
|
6449
|
+
}
|
|
6450
|
+
throw new Error("JPEG has no SOF segment");
|
|
6451
|
+
}
|
|
6452
|
+
function webpSize(b) {
|
|
6453
|
+
const chunk = b.length >= 16 ? b.toString("latin1", 12, 16) : "";
|
|
6454
|
+
if (chunk === "VP8 ") {
|
|
6455
|
+
if (b.length < 30) throw new Error(`WebP VP8 chunk is truncated: ${b.length} bytes, needs 30`);
|
|
6456
|
+
if (b[23] !== 157 || b[24] !== 1 || b[25] !== 42)
|
|
6457
|
+
throw new Error("WebP VP8 chunk has no keyframe sync code");
|
|
6458
|
+
return { width: b.readUInt16LE(26) & 16383, height: b.readUInt16LE(28) & 16383 };
|
|
6459
|
+
}
|
|
6460
|
+
if (chunk === "VP8L") {
|
|
6461
|
+
if (b.length < 25) throw new Error(`WebP VP8L chunk is truncated: ${b.length} bytes, needs 25`);
|
|
6462
|
+
if (b[20] !== 47) throw new Error("WebP VP8L chunk has no 0x2f signature");
|
|
6463
|
+
const bits = b.readUInt32LE(21);
|
|
6464
|
+
return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
|
|
6465
|
+
}
|
|
6466
|
+
if (chunk === "VP8X") {
|
|
6467
|
+
if (b.length < 30) throw new Error(`WebP VP8X chunk is truncated: ${b.length} bytes, needs 30`);
|
|
6468
|
+
return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
|
|
6469
|
+
}
|
|
6470
|
+
throw new Error(`WebP opens with chunk "${chunk}", not one of VP8 , VP8L, VP8X`);
|
|
6471
|
+
}
|
|
6472
|
+
function avifSize(b) {
|
|
6473
|
+
const meta = find(b, 0, b.length, "meta");
|
|
6474
|
+
if (!meta) throw new Error("AVIF has no meta box (truncated, or not an image item)");
|
|
6475
|
+
const iprp = find(b, meta.start + 4, meta.end, "iprp");
|
|
6476
|
+
const ipco = iprp && find(b, iprp.start, iprp.end, "ipco");
|
|
6477
|
+
if (!ipco) throw new Error("AVIF has no ipco box (no item properties to read a size from)");
|
|
6478
|
+
let best = { width: 0, height: 0 };
|
|
6479
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
6480
|
+
if (box.type !== "ispe" || box.end - box.start < 12) continue;
|
|
6481
|
+
const found = { width: b.readUInt32BE(box.start + 4), height: b.readUInt32BE(box.start + 8) };
|
|
6482
|
+
if (found.width * found.height > best.width * best.height) best = found;
|
|
6483
|
+
}
|
|
6484
|
+
if (best.width < 1 || best.height < 1) throw new Error("AVIF has no usable ispe property");
|
|
6485
|
+
return cleanAperture(b, ipco, best) ?? best;
|
|
6486
|
+
}
|
|
6487
|
+
function cleanAperture(b, ipco, ispe) {
|
|
6488
|
+
for (const box of boxes(b, ipco.start, ipco.end)) {
|
|
6489
|
+
if (box.type !== "clap" || box.end - box.start < 16) continue;
|
|
6490
|
+
const at = (offset) => b.readUInt32BE(box.start + offset);
|
|
6491
|
+
if (at(4) === 0 || at(12) === 0) continue;
|
|
6492
|
+
const width = Math.round(at(0) / at(4));
|
|
6493
|
+
const height = Math.round(at(8) / at(12));
|
|
6494
|
+
if (width > ispe.width || height > ispe.height) continue;
|
|
6495
|
+
if (width * 2 < ispe.width || height * 2 < ispe.height) continue;
|
|
6496
|
+
return { width, height };
|
|
6497
|
+
}
|
|
6498
|
+
return void 0;
|
|
6499
|
+
}
|
|
6500
|
+
function find(b, from, to, type) {
|
|
6501
|
+
for (const box of boxes(b, from, to)) if (box.type === type) return box;
|
|
6502
|
+
return void 0;
|
|
6503
|
+
}
|
|
6504
|
+
function* boxes(b, from, to) {
|
|
6505
|
+
let i = from;
|
|
6506
|
+
while (i + 8 <= to) {
|
|
6507
|
+
let size3 = b.readUInt32BE(i);
|
|
6508
|
+
const type = b.toString("latin1", i + 4, i + 8);
|
|
6509
|
+
let start = i + 8;
|
|
6510
|
+
if (size3 === 1) {
|
|
6511
|
+
if (i + 16 > to) return;
|
|
6512
|
+
const large = b.readBigUInt64BE(i + 8);
|
|
6513
|
+
if (large > BigInt(Number.MAX_SAFE_INTEGER)) return;
|
|
6514
|
+
size3 = Number(large);
|
|
6515
|
+
start = i + 16;
|
|
6516
|
+
} else if (size3 === 0) {
|
|
6517
|
+
size3 = to - i;
|
|
6518
|
+
}
|
|
6519
|
+
if (size3 < start - i || i + size3 > to) return;
|
|
6520
|
+
yield { type, start, end: i + size3 };
|
|
6521
|
+
i += size3;
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
function svgFigureSize(b) {
|
|
6525
|
+
const moving = /<(?:animate|animateTransform|animateMotion|set|script)\b|@keyframes\b/i.exec(
|
|
6526
|
+
b.toString("utf8")
|
|
6527
|
+
);
|
|
6528
|
+
if (moving)
|
|
6529
|
+
throw new Error(
|
|
6530
|
+
`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.`
|
|
6531
|
+
);
|
|
6532
|
+
return svgSize(b);
|
|
6533
|
+
}
|
|
6534
|
+
function svgSize(bytes) {
|
|
6535
|
+
const text2 = bytes.toString("utf8", 0, Math.min(bytes.length, SVG_WINDOW));
|
|
6536
|
+
const tag = /<svg\b[^>]*>/i.exec(text2)?.[0];
|
|
6537
|
+
if (!tag) throw new Error(`SVG root element is not closed within ${SVG_WINDOW} bytes`);
|
|
6538
|
+
const width = cssPixels(attrOf(tag, "width"));
|
|
6539
|
+
const height = cssPixels(attrOf(tag, "height"));
|
|
6540
|
+
if (width !== void 0 && height !== void 0) return { width, height };
|
|
6541
|
+
const box = attrOf(tag, "viewBox")?.trim().split(/[\s,]+/).map(Number);
|
|
6542
|
+
const w = box?.[2];
|
|
6543
|
+
const h = box?.[3];
|
|
6544
|
+
if (box?.length === 4 && w !== void 0 && h !== void 0 && w > 0 && h > 0)
|
|
6545
|
+
return { width: w, height: h };
|
|
6546
|
+
throw new Error("SVG declares no absolute width and height, and no usable viewBox");
|
|
6547
|
+
}
|
|
6548
|
+
function attrOf(tag, name) {
|
|
6549
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(tag);
|
|
6550
|
+
return m?.[1] ?? m?.[2];
|
|
6551
|
+
}
|
|
6552
|
+
var UNIT = {
|
|
6553
|
+
"": 1,
|
|
6554
|
+
px: 1,
|
|
6555
|
+
pt: 96 / 72,
|
|
6556
|
+
pc: 16,
|
|
6557
|
+
in: 96,
|
|
6558
|
+
cm: 96 / 2.54,
|
|
6559
|
+
mm: 96 / 25.4,
|
|
6560
|
+
q: 96 / 101.6,
|
|
6561
|
+
em: 16,
|
|
6562
|
+
rem: 16
|
|
6563
|
+
};
|
|
6564
|
+
function cssPixels(value) {
|
|
6565
|
+
if (value === void 0) return void 0;
|
|
6566
|
+
const m = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*([a-z%]*)\s*$/i.exec(value);
|
|
6567
|
+
if (!m?.[1]) return void 0;
|
|
6568
|
+
const scale = UNIT[(m[2] ?? "").toLowerCase()];
|
|
6569
|
+
if (scale === void 0) return void 0;
|
|
6570
|
+
const px = Number(m[1]) * scale;
|
|
6571
|
+
return px > 0 ? px : void 0;
|
|
6572
|
+
}
|
|
6573
|
+
|
|
6574
|
+
// src/images/providers.ts
|
|
6575
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
6576
|
+
import { mkdtemp as mkdtemp2, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
|
|
6577
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
6578
|
+
import { join as join4, resolve } from "node:path";
|
|
6579
|
+
import { z as z4 } from "zod";
|
|
6580
|
+
|
|
6581
|
+
// src/plan/codex.ts
|
|
6582
|
+
import { spawn } from "node:child_process";
|
|
6583
|
+
import { mkdtemp, readFile as readFile4, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
6584
|
+
import { tmpdir } from "node:os";
|
|
6585
|
+
import { join as join3 } from "node:path";
|
|
6586
|
+
import { z as z3 } from "zod";
|
|
6587
|
+
|
|
6588
|
+
// src/plan/arc.ts
|
|
6589
|
+
var ARC_ROLES = ["intro", "background", "limitations", "conclusion"];
|
|
6590
|
+
function requiredRoles(beatCount) {
|
|
5716
6591
|
if (beatCount >= 8) return ARC_ROLES;
|
|
5717
6592
|
if (beatCount >= 5) return ["limitations", "conclusion"];
|
|
5718
6593
|
return [];
|
|
@@ -6521,9 +7396,28 @@ function renderSource(source) {
|
|
|
6521
7396
|
} else {
|
|
6522
7397
|
const n3 = source.figures.length;
|
|
6523
7398
|
out.push(`${n3 === 1 ? "1 figure" : `${n3} figures`} in this document.`);
|
|
7399
|
+
if (source.figures.some((f) => f.kind === "clip")) {
|
|
7400
|
+
out.push(
|
|
7401
|
+
"",
|
|
7402
|
+
"A CLIP is a figure whose asset is video. Cite it exactly as you cite any other",
|
|
7403
|
+
"figure \u2014 `figureId` on the beat, `[figure id]` in evidence \u2014 and pick it for the",
|
|
7404
|
+
"same reason: it is the picture that carries the point.",
|
|
7405
|
+
"WHAT IT COSTS, and it decides whether a beat is worth spending on one: the deck",
|
|
7406
|
+
"holds a clip PAUSED AND MUTED, so a viewer clicking through sees one frame until",
|
|
7407
|
+
"they press play, and the rendered video shows that same frame unless the clip is",
|
|
7408
|
+
"a file this deck actually holds. A clip listed as watchable only at a link is a",
|
|
7409
|
+
"page whose video we could not download, and its still is all any format will ever",
|
|
7410
|
+
"show. So write the beat for the FRAME: claim what the picture states, and never",
|
|
7411
|
+
"narrate a motion the still does not."
|
|
7412
|
+
);
|
|
7413
|
+
}
|
|
6524
7414
|
const headings = new Map(source.sections.map((s) => [s.id, s.heading]));
|
|
6525
7415
|
for (const f of source.figures) {
|
|
6526
|
-
|
|
7416
|
+
const size3 = 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}`;
|
|
7417
|
+
out.push("", `[figure ${f.id}] ${size3} \u2014 ${f.caption}`);
|
|
7418
|
+
if (f.kind === "clip" && f.href) {
|
|
7419
|
+
out.push(` watchable only at ${f.href} \u2014 the deck shows its still, never the video`);
|
|
7420
|
+
}
|
|
6527
7421
|
const heading = f.sectionId === void 0 ? void 0 : headings.get(f.sectionId);
|
|
6528
7422
|
if (f.sectionId !== void 0) {
|
|
6529
7423
|
out.push(` under: [section ${f.sectionId}]${heading ? ` ${heading}` : ""}`);
|
|
@@ -6671,7 +7565,7 @@ function assertInsideResolves(storyboard, source) {
|
|
|
6671
7565
|
let parts;
|
|
6672
7566
|
try {
|
|
6673
7567
|
const sid = `s${i}`;
|
|
6674
|
-
const scene = emitScene(previous, { source, format, theme: ink, sid });
|
|
7568
|
+
const scene = emitScene(previous, { source, format, theme: ink, sid, start: 0 });
|
|
6675
7569
|
drawn = enterableIds(sid, scene.html).map((id2) => id2.replace(`${sid}-`, ""));
|
|
6676
7570
|
parts = scene.parts;
|
|
6677
7571
|
} catch {
|
|
@@ -6774,7 +7668,7 @@ function hideFromPlanner(node, hidden) {
|
|
|
6774
7668
|
}
|
|
6775
7669
|
function schemaFor(prefs) {
|
|
6776
7670
|
return hideFromPlanner(
|
|
6777
|
-
forStructuredOutput(
|
|
7671
|
+
forStructuredOutput(z3.toJSONSchema(storyboardSchema, { io: "input" })),
|
|
6778
7672
|
plannerInvisible(prefs)
|
|
6779
7673
|
);
|
|
6780
7674
|
}
|
|
@@ -6793,7 +7687,7 @@ async function codexPlanner(source, opts = {}) {
|
|
|
6793
7687
|
...opts.model === void 0 ? {} : { model: opts.model },
|
|
6794
7688
|
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
6795
7689
|
});
|
|
6796
|
-
const raw2 = await
|
|
7690
|
+
const raw2 = await readFile4(outPath, "utf8").catch(() => "");
|
|
6797
7691
|
if (raw2.trim() === "") {
|
|
6798
7692
|
throw new Error("Codex produced no final message. Re-run, or try a shorter source.");
|
|
6799
7693
|
}
|
|
@@ -6866,7 +7760,7 @@ function codexCommand(args) {
|
|
|
6866
7760
|
}
|
|
6867
7761
|
function runCodex(args) {
|
|
6868
7762
|
const { argv, env } = codexCommand(args);
|
|
6869
|
-
return new Promise((
|
|
7763
|
+
return new Promise((resolve6, reject) => {
|
|
6870
7764
|
const child = spawn("codex", argv, {
|
|
6871
7765
|
stdio: ["pipe", "ignore", "pipe"],
|
|
6872
7766
|
...env === void 0 ? {} : { env }
|
|
@@ -6889,7 +7783,7 @@ function runCodex(args) {
|
|
|
6889
7783
|
});
|
|
6890
7784
|
child.on("close", (code) => {
|
|
6891
7785
|
clearTimeout(timer);
|
|
6892
|
-
if (code === 0) return
|
|
7786
|
+
if (code === 0) return resolve6();
|
|
6893
7787
|
reject(
|
|
6894
7788
|
new Error(`codex exec exited ${code}.
|
|
6895
7789
|
${stderr.trim().split("\n").slice(-8).join("\n")}`)
|
|
@@ -6922,11 +7816,11 @@ var DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
|
6922
7816
|
var DEFAULT_MODEL = "gpt-image-2";
|
|
6923
7817
|
var REQUEST_TIMEOUT_MS = 12e4;
|
|
6924
7818
|
var MAX_BYTES = 16 * 1024 * 1024;
|
|
6925
|
-
var generatedSchema =
|
|
6926
|
-
data:
|
|
7819
|
+
var generatedSchema = z4.object({
|
|
7820
|
+
data: z4.array(z4.object({ b64_json: z4.string().optional(), url: z4.string().optional() })).optional()
|
|
6927
7821
|
});
|
|
6928
|
-
var failedSchema =
|
|
6929
|
-
error:
|
|
7822
|
+
var failedSchema = z4.object({
|
|
7823
|
+
error: z4.object({ code: z4.string().nullish(), type: z4.string().nullish() }).nullish()
|
|
6930
7824
|
});
|
|
6931
7825
|
function openaiImages(opts) {
|
|
6932
7826
|
const base = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
@@ -6951,8 +7845,8 @@ function openaiImages(opts) {
|
|
|
6951
7845
|
}),
|
|
6952
7846
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
6953
7847
|
});
|
|
6954
|
-
const body = await
|
|
6955
|
-
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${
|
|
7848
|
+
const body = await readCapped2(res);
|
|
7849
|
+
if (!res.ok) throw new Error(`openai images: HTTP ${res.status}${reason2(body)}`);
|
|
6956
7850
|
const first = generatedSchema.safeParse(parseJson(body)).data?.data?.[0];
|
|
6957
7851
|
if (first?.b64_json) return raster(Buffer.from(first.b64_json, "base64"), "openai images");
|
|
6958
7852
|
if (!first?.url) throw new Error("openai images: answer carried neither b64_json nor url");
|
|
@@ -6965,14 +7859,14 @@ function openaiImages(opts) {
|
|
|
6965
7859
|
redirect: "error",
|
|
6966
7860
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
6967
7861
|
});
|
|
6968
|
-
const bytes = await
|
|
7862
|
+
const bytes = await readCapped2(picture);
|
|
6969
7863
|
if (!picture.ok)
|
|
6970
7864
|
throw new Error(`openai images: HTTP ${picture.status} fetching the picture`);
|
|
6971
7865
|
return raster(bytes, "openai images");
|
|
6972
7866
|
}
|
|
6973
7867
|
};
|
|
6974
7868
|
}
|
|
6975
|
-
function
|
|
7869
|
+
function reason2(body) {
|
|
6976
7870
|
const error = failedSchema.safeParse(parseJson(body)).data?.error;
|
|
6977
7871
|
const code = error?.code ?? error?.type;
|
|
6978
7872
|
return code ? ` (${code})` : "";
|
|
@@ -6984,7 +7878,7 @@ function parseJson(body) {
|
|
|
6984
7878
|
return void 0;
|
|
6985
7879
|
}
|
|
6986
7880
|
}
|
|
6987
|
-
async function
|
|
7881
|
+
async function readCapped2(res) {
|
|
6988
7882
|
const over = `openai images: answer is over ${MAX_BYTES >> 20} MB`;
|
|
6989
7883
|
if (Number(res.headers.get("content-length")) > MAX_BYTES) throw new Error(over);
|
|
6990
7884
|
const reader = res.body?.getReader();
|
|
@@ -7014,10 +7908,10 @@ var ANSWER_SCHEMA = {
|
|
|
7014
7908
|
required: ["ok", "file", "reason"],
|
|
7015
7909
|
additionalProperties: false
|
|
7016
7910
|
};
|
|
7017
|
-
var answerSchema =
|
|
7018
|
-
ok:
|
|
7019
|
-
file:
|
|
7020
|
-
reason:
|
|
7911
|
+
var answerSchema = z4.object({
|
|
7912
|
+
ok: z4.boolean(),
|
|
7913
|
+
file: z4.string().nullish(),
|
|
7914
|
+
reason: z4.string().nullish()
|
|
7021
7915
|
});
|
|
7022
7916
|
function codexImages(opts = {}) {
|
|
7023
7917
|
const run4 = opts.run ?? runCodex;
|
|
@@ -7043,7 +7937,7 @@ function codexImages(opts = {}) {
|
|
|
7043
7937
|
cwd: dir,
|
|
7044
7938
|
sandbox: "workspace-write"
|
|
7045
7939
|
});
|
|
7046
|
-
const raw2 = await
|
|
7940
|
+
const raw2 = await readFile5(outPath, "utf8").catch(() => "");
|
|
7047
7941
|
const answer = answerSchema.safeParse(parseJson(Buffer.from(raw2)));
|
|
7048
7942
|
if (!answer.success) throw new Error("codex could not generate a picture: no final answer");
|
|
7049
7943
|
if (!answer.data.ok) {
|
|
@@ -7054,7 +7948,7 @@ function codexImages(opts = {}) {
|
|
|
7054
7948
|
const candidates2 = [join4(dir, "picture.png")];
|
|
7055
7949
|
if (answer.data.file) candidates2.push(resolve(dir, answer.data.file));
|
|
7056
7950
|
for (const path2 of candidates2) {
|
|
7057
|
-
const bytes = await
|
|
7951
|
+
const bytes = await readFile5(path2).catch(() => null);
|
|
7058
7952
|
if (bytes) return raster(bytes, "codex");
|
|
7059
7953
|
}
|
|
7060
7954
|
throw new Error("codex said ok but wrote no picture.png");
|
|
@@ -7094,7 +7988,7 @@ function mulberry32(seed) {
|
|
|
7094
7988
|
}
|
|
7095
7989
|
function drawSvg(req) {
|
|
7096
7990
|
const { width: W, height: H } = SIZE[req.aspect];
|
|
7097
|
-
const seed =
|
|
7991
|
+
const seed = createHash4("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
|
|
7098
7992
|
const rand = mulberry32(seed.readUInt32BE(0));
|
|
7099
7993
|
const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
|
|
7100
7994
|
const count = 6 + Math.floor(rand() * 5);
|
|
@@ -7125,11 +8019,6 @@ function drawSvg(req) {
|
|
|
7125
8019
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join("")}</svg>
|
|
7126
8020
|
`;
|
|
7127
8021
|
}
|
|
7128
|
-
function svgSize(bytes) {
|
|
7129
|
-
const m = /viewBox="0 0 (\d+) (\d+)"/.exec(bytes.toString("utf8", 0, 200));
|
|
7130
|
-
if (!m) throw new Error("svg has no viewBox");
|
|
7131
|
-
return { width: Number(m[1]), height: Number(m[2]) };
|
|
7132
|
-
}
|
|
7133
8022
|
function toolSvg() {
|
|
7134
8023
|
return {
|
|
7135
8024
|
id: "svg",
|
|
@@ -7173,7 +8062,7 @@ function imageChain(images, backend) {
|
|
|
7173
8062
|
}
|
|
7174
8063
|
|
|
7175
8064
|
// src/images/illustrate.ts
|
|
7176
|
-
var
|
|
8065
|
+
var EXT2 = {
|
|
7177
8066
|
"image/png": ".png",
|
|
7178
8067
|
"image/jpeg": ".jpg",
|
|
7179
8068
|
"image/svg+xml": ".svg"
|
|
@@ -7211,6 +8100,9 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7211
8100
|
const picture = await draw(provider, req, name, opts.assetsDir);
|
|
7212
8101
|
const figure = {
|
|
7213
8102
|
id: slot.figureId,
|
|
8103
|
+
// An illustration is always a still: every provider draws a picture,
|
|
8104
|
+
// and there is no rung that returns a video.
|
|
8105
|
+
kind: "image",
|
|
7214
8106
|
src: picture.src,
|
|
7215
8107
|
caption: slot.brief.caption,
|
|
7216
8108
|
width: picture.width,
|
|
@@ -7229,8 +8121,8 @@ async function illustrate(storyboard, source, opts) {
|
|
|
7229
8121
|
} catch (err) {
|
|
7230
8122
|
const after = live[j + 1];
|
|
7231
8123
|
if (!after) throw err;
|
|
7232
|
-
const
|
|
7233
|
-
step2(`illustrate: ${slot.label} via ${provider.id} failed (${
|
|
8124
|
+
const why3 = err instanceof Error ? err.message : String(err);
|
|
8125
|
+
step2(`illustrate: ${slot.label} via ${provider.id} failed (${why3}); trying ${after.id}`);
|
|
7234
8126
|
dropped.add(provider.id);
|
|
7235
8127
|
}
|
|
7236
8128
|
}
|
|
@@ -7280,17 +8172,17 @@ function slots(storyboard, known) {
|
|
|
7280
8172
|
return out;
|
|
7281
8173
|
}
|
|
7282
8174
|
function cacheKey(providerId, req) {
|
|
7283
|
-
return
|
|
8175
|
+
return createHash5("sha256").update(["v1", providerId, req.model ?? "", req.aspect, req.style, req.prompt].join("\n")).digest("hex");
|
|
7284
8176
|
}
|
|
7285
8177
|
async function draw(provider, req, name, dir) {
|
|
7286
|
-
for (const ext of Object.values(
|
|
8178
|
+
for (const ext of Object.values(EXT2)) {
|
|
7287
8179
|
const src2 = `${name}${ext}`;
|
|
7288
|
-
const bytes = await
|
|
8180
|
+
const bytes = await readFile6(join5(dir, src2)).catch(() => null);
|
|
7289
8181
|
if (bytes) return { src: src2, ...sizeOf(bytes, ext), cached: true };
|
|
7290
8182
|
}
|
|
7291
8183
|
await provider.check();
|
|
7292
8184
|
const img = await provider.generate(req);
|
|
7293
|
-
const src = `${name}${
|
|
8185
|
+
const src = `${name}${EXT2[img.mime]}`;
|
|
7294
8186
|
const tmp = join5(dir, `.${name}.tmp`);
|
|
7295
8187
|
try {
|
|
7296
8188
|
await writeFile5(tmp, img.bytes);
|
|
@@ -7306,8 +8198,8 @@ function sizeOf(bytes, ext) {
|
|
|
7306
8198
|
|
|
7307
8199
|
// src/narrate/tts.ts
|
|
7308
8200
|
import { spawn as spawn2 } from "node:child_process";
|
|
7309
|
-
import { createHash as
|
|
7310
|
-
import { mkdir as mkdir4, readFile as
|
|
8201
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
8202
|
+
import { mkdir as mkdir4, readFile as readFile7, rm as rm4, writeFile as writeFile6 } from "node:fs/promises";
|
|
7311
8203
|
import { homedir } from "node:os";
|
|
7312
8204
|
import { join as join6 } from "node:path";
|
|
7313
8205
|
var MISSING = [
|
|
@@ -7341,18 +8233,18 @@ async function answersHelp(argv) {
|
|
|
7341
8233
|
}
|
|
7342
8234
|
var resolved = null;
|
|
7343
8235
|
function resolveEdgeTts(can = answersHelp) {
|
|
7344
|
-
if (can !== answersHelp) return
|
|
7345
|
-
resolved ??=
|
|
8236
|
+
if (can !== answersHelp) return find2(can);
|
|
8237
|
+
resolved ??= find2(can);
|
|
7346
8238
|
return resolved;
|
|
7347
8239
|
}
|
|
7348
|
-
async function
|
|
8240
|
+
async function find2(can) {
|
|
7349
8241
|
for (const argv of candidates()) {
|
|
7350
8242
|
if (await can(argv)) return argv;
|
|
7351
8243
|
}
|
|
7352
8244
|
throw new Error(MISSING);
|
|
7353
8245
|
}
|
|
7354
8246
|
function runArgv(cmd, args) {
|
|
7355
|
-
return new Promise((
|
|
8247
|
+
return new Promise((resolve6) => {
|
|
7356
8248
|
const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
7357
8249
|
let stdout = "";
|
|
7358
8250
|
let stderr = "";
|
|
@@ -7362,8 +8254,8 @@ function runArgv(cmd, args) {
|
|
|
7362
8254
|
child.stderr.on("data", (b) => {
|
|
7363
8255
|
stderr += b.toString();
|
|
7364
8256
|
});
|
|
7365
|
-
child.on("error", (e) =>
|
|
7366
|
-
child.on("close", (code) =>
|
|
8257
|
+
child.on("error", (e) => resolve6({ code: -1, stderr: String(e), stdout }));
|
|
8258
|
+
child.on("close", (code) => resolve6({ code: code ?? -1, stderr, stdout }));
|
|
7367
8259
|
});
|
|
7368
8260
|
}
|
|
7369
8261
|
var edgeTts = {
|
|
@@ -7430,7 +8322,7 @@ function edgeProvider(runner = edgeTts) {
|
|
|
7430
8322
|
async speak(req) {
|
|
7431
8323
|
const subs = `${req.audio.replace(/\.mp3$/, "")}.srt`;
|
|
7432
8324
|
await runner.speak({ ...req, subs });
|
|
7433
|
-
const raw2 = await
|
|
8325
|
+
const raw2 = await readFile7(subs, "utf8").catch(() => "");
|
|
7434
8326
|
await rm4(subs, { force: true });
|
|
7435
8327
|
const cues = parseCues(raw2);
|
|
7436
8328
|
const measured = await runner.measure(req.audio);
|
|
@@ -7440,7 +8332,7 @@ function edgeProvider(runner = edgeTts) {
|
|
|
7440
8332
|
};
|
|
7441
8333
|
}
|
|
7442
8334
|
function cacheKey2(text2, voice, rate, pitch) {
|
|
7443
|
-
return
|
|
8335
|
+
return createHash6("sha256").update([text2, voice, rate, pitch].join("\0")).digest("hex").slice(0, 16);
|
|
7444
8336
|
}
|
|
7445
8337
|
async function synthesize(text2, opts) {
|
|
7446
8338
|
const rate = opts.rate ?? "+0%";
|
|
@@ -7467,7 +8359,7 @@ async function synthesize(text2, opts) {
|
|
|
7467
8359
|
}
|
|
7468
8360
|
async function readSidecar(path2) {
|
|
7469
8361
|
try {
|
|
7470
|
-
const parsed = JSON.parse(await
|
|
8362
|
+
const parsed = JSON.parse(await readFile7(path2, "utf8"));
|
|
7471
8363
|
const s = parsed;
|
|
7472
8364
|
return typeof s?.seconds === "number" && s.seconds > 0 && Array.isArray(s.cues) ? s : null;
|
|
7473
8365
|
} catch {
|
|
@@ -7520,7 +8412,7 @@ function stopCount(holds) {
|
|
|
7520
8412
|
return Math.max(1, usable.size);
|
|
7521
8413
|
}
|
|
7522
8414
|
function stopsFor(beat, source, format, sid = "s1") {
|
|
7523
|
-
const ctx = { source, format, theme: ink, sid };
|
|
8415
|
+
const ctx = { source, format, theme: ink, sid, start: 0 };
|
|
7524
8416
|
try {
|
|
7525
8417
|
return stopCount(emitScene(beat, ctx).holds);
|
|
7526
8418
|
} catch {
|
|
@@ -7559,7 +8451,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7559
8451
|
for (const [i, beat] of storyboard.beats.entries()) {
|
|
7560
8452
|
const text2 = beat.narration?.trim();
|
|
7561
8453
|
if (!text2) continue;
|
|
7562
|
-
const
|
|
8454
|
+
const segments2 = [];
|
|
7563
8455
|
const stops = Math.min(stopsFor(beat, source, format, `s${i + 1}`), paced.speakingStops);
|
|
7564
8456
|
const plan = planSegments(text2, stops);
|
|
7565
8457
|
for (const [stop, line2] of plan.entries()) {
|
|
@@ -7571,7 +8463,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7571
8463
|
dir: opts.dir,
|
|
7572
8464
|
runner: opts.runner
|
|
7573
8465
|
});
|
|
7574
|
-
|
|
8466
|
+
segments2.push({
|
|
7575
8467
|
stop,
|
|
7576
8468
|
text: line2,
|
|
7577
8469
|
// Content-addressed and flat, so the path is the filename and the deck
|
|
@@ -7581,209 +8473,58 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
7581
8473
|
cues: speech.cues
|
|
7582
8474
|
});
|
|
7583
8475
|
}
|
|
7584
|
-
if (
|
|
8476
|
+
if (segments2.length > 0) beats[beat.id] = segments2;
|
|
7585
8477
|
}
|
|
7586
8478
|
return { voice, beats };
|
|
7587
8479
|
}
|
|
7588
8480
|
|
|
7589
|
-
// src/pack/
|
|
7590
|
-
import {
|
|
7591
|
-
import {
|
|
7592
|
-
import {
|
|
7593
|
-
var
|
|
7594
|
-
|
|
7595
|
-
"youtube-nocookie.com",
|
|
7596
|
-
"youtu.be",
|
|
7597
|
-
"vimeo.com",
|
|
7598
|
-
"dailymotion.com",
|
|
7599
|
-
"dai.ly",
|
|
7600
|
-
"twitch.tv",
|
|
7601
|
-
"loom.com",
|
|
7602
|
-
"wistia.com",
|
|
7603
|
-
"wistia.net",
|
|
7604
|
-
"streamable.com",
|
|
7605
|
-
"bilibili.com",
|
|
7606
|
-
"soundcloud.com",
|
|
7607
|
-
"tiktok.com"
|
|
7608
|
-
];
|
|
7609
|
-
var FILE_EXT = /* @__PURE__ */ new Set([
|
|
8481
|
+
// src/pack/pack.ts
|
|
8482
|
+
import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
|
|
8483
|
+
import { dirname, extname as extname3 } from "node:path";
|
|
8484
|
+
import { unzipSync, zipSync } from "fflate";
|
|
8485
|
+
var MTIME = Date.UTC(1980, 0, 2, 12);
|
|
8486
|
+
var STORED = /* @__PURE__ */ new Set([
|
|
7610
8487
|
".png",
|
|
7611
8488
|
".jpg",
|
|
7612
8489
|
".jpeg",
|
|
7613
8490
|
".gif",
|
|
7614
8491
|
".webp",
|
|
7615
8492
|
".avif",
|
|
7616
|
-
".
|
|
8493
|
+
".mp3",
|
|
8494
|
+
".m4a",
|
|
7617
8495
|
".mp4",
|
|
7618
8496
|
".webm",
|
|
7619
8497
|
".mov",
|
|
7620
|
-
".m4v",
|
|
7621
|
-
".mp3",
|
|
7622
|
-
".m4a",
|
|
7623
|
-
".wav",
|
|
7624
8498
|
".ogg",
|
|
7625
8499
|
".opus",
|
|
7626
|
-
".
|
|
8500
|
+
".woff",
|
|
8501
|
+
".woff2"
|
|
7627
8502
|
]);
|
|
7628
|
-
function
|
|
7629
|
-
const
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
}
|
|
7639
|
-
async function planMedia(assets, fetcher = fetchAsset) {
|
|
7640
|
-
const plan = {
|
|
7641
|
-
media: [],
|
|
7642
|
-
files: {},
|
|
7643
|
-
bakedCount: 0,
|
|
7644
|
-
bakedBytes: 0,
|
|
7645
|
-
demoted: [],
|
|
7646
|
-
promoted: []
|
|
8503
|
+
async function writePack(pack2, files, out) {
|
|
8504
|
+
const manifest = packSchema.parse(pack2);
|
|
8505
|
+
for (const m of manifest.media) {
|
|
8506
|
+
if (m.policy !== "bake") continue;
|
|
8507
|
+
if (!m.path) throw new Error(`media "${m.id}" is baked but has no path`);
|
|
8508
|
+
if (!(m.path in files)) throw new Error(`media "${m.id}" is baked but ${m.path} was not given`);
|
|
8509
|
+
}
|
|
8510
|
+
const entries = {
|
|
8511
|
+
"deck.json": new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}
|
|
8512
|
+
`)
|
|
7647
8513
|
};
|
|
7648
|
-
for (const
|
|
7649
|
-
const
|
|
7650
|
-
if (
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
}
|
|
7655
|
-
if (asset.prefer === "link") plan.promoted.push(asset.id);
|
|
7656
|
-
const got = await fetcher(asset.url);
|
|
7657
|
-
const mime = clean(got.mime) ?? asset.mime;
|
|
7658
|
-
if (mime === "text/html") {
|
|
7659
|
-
plan.demoted.push(asset.id);
|
|
7660
|
-
plan.media.push({ id: asset.id, policy: "link", url: asset.url, mime });
|
|
7661
|
-
continue;
|
|
7662
|
-
}
|
|
7663
|
-
const path2 = `media/${bakedName(asset.id, asset.url, mime)}`;
|
|
7664
|
-
plan.files[path2] = got.bytes;
|
|
7665
|
-
plan.bakedCount += 1;
|
|
7666
|
-
plan.bakedBytes += got.bytes.length;
|
|
7667
|
-
plan.media.push({ id: asset.id, policy: "bake", path: path2, mime, bytes: got.bytes.length });
|
|
8514
|
+
for (const path2 of Object.keys(files).sort()) {
|
|
8515
|
+
const bytes = files[path2];
|
|
8516
|
+
if (!bytes) continue;
|
|
8517
|
+
if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
|
|
8518
|
+
check(path2);
|
|
8519
|
+
entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
|
|
7668
8520
|
}
|
|
7669
|
-
|
|
7670
|
-
}
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
const embedded = plan.media.filter((m) => m.policy === "embed").length;
|
|
7674
|
-
const parts = [`${plan.bakedCount} baked (${size(plan.bakedBytes)})`];
|
|
7675
|
-
if (linked) parts.push(`${linked} linked`);
|
|
7676
|
-
if (embedded) parts.push(`${embedded} embedded`);
|
|
7677
|
-
if (plan.demoted.length) parts.push(`${plan.demoted.length} not bakeable`);
|
|
7678
|
-
return parts.join(", ");
|
|
7679
|
-
}
|
|
7680
|
-
function bakedName(id2, url, mime) {
|
|
7681
|
-
const slug = id2.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "asset";
|
|
7682
|
-
const hash = createHash6("sha256").update(url).digest("hex").slice(0, 8);
|
|
7683
|
-
return `${slug}-${hash}${extOf(url) || extFor(mime)}`;
|
|
8521
|
+
const zip = zipSync(entries, { mtime: MTIME });
|
|
8522
|
+
await mkdir5(dirname(out), { recursive: true });
|
|
8523
|
+
await writeFile7(out, zip);
|
|
8524
|
+
return zip.length;
|
|
7684
8525
|
}
|
|
7685
|
-
function
|
|
7686
|
-
|
|
7687
|
-
const ext = extname2(path2).toLowerCase();
|
|
7688
|
-
return FILE_EXT.has(ext) ? ext : "";
|
|
7689
|
-
}
|
|
7690
|
-
var MIME_EXT = {
|
|
7691
|
-
"image/png": ".png",
|
|
7692
|
-
"image/jpeg": ".jpg",
|
|
7693
|
-
"image/gif": ".gif",
|
|
7694
|
-
"image/webp": ".webp",
|
|
7695
|
-
"image/avif": ".avif",
|
|
7696
|
-
"image/svg+xml": ".svg",
|
|
7697
|
-
"video/mp4": ".mp4",
|
|
7698
|
-
"video/webm": ".webm",
|
|
7699
|
-
"audio/mpeg": ".mp3",
|
|
7700
|
-
"application/pdf": ".pdf"
|
|
7701
|
-
};
|
|
7702
|
-
function extFor(mime) {
|
|
7703
|
-
return (mime && MIME_EXT[mime]) ?? ".bin";
|
|
7704
|
-
}
|
|
7705
|
-
function hostOf(url) {
|
|
7706
|
-
try {
|
|
7707
|
-
const u = new URL(url);
|
|
7708
|
-
if (u.protocol === "file:" || u.protocol === "data:") return null;
|
|
7709
|
-
return u.hostname.toLowerCase().replace(/^www\./, "");
|
|
7710
|
-
} catch {
|
|
7711
|
-
return null;
|
|
7712
|
-
}
|
|
7713
|
-
}
|
|
7714
|
-
function clean(mime) {
|
|
7715
|
-
return mime?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
7716
|
-
}
|
|
7717
|
-
function size(bytes) {
|
|
7718
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
7719
|
-
const units = ["KB", "MB", "GB"];
|
|
7720
|
-
let n3 = bytes / 1024;
|
|
7721
|
-
let i = 0;
|
|
7722
|
-
while (n3 >= 1024 && i < units.length - 1) {
|
|
7723
|
-
n3 /= 1024;
|
|
7724
|
-
i += 1;
|
|
7725
|
-
}
|
|
7726
|
-
return `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} ${units[i]}`;
|
|
7727
|
-
}
|
|
7728
|
-
async function fetchAsset(url) {
|
|
7729
|
-
if (/^(https?|data):/i.test(url)) {
|
|
7730
|
-
const res = await fetch(url);
|
|
7731
|
-
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
7732
|
-
return {
|
|
7733
|
-
bytes: new Uint8Array(await res.arrayBuffer()),
|
|
7734
|
-
mime: res.headers.get("content-type") ?? void 0
|
|
7735
|
-
};
|
|
7736
|
-
}
|
|
7737
|
-
return { bytes: new Uint8Array(await readFile7(url)) };
|
|
7738
|
-
}
|
|
7739
|
-
|
|
7740
|
-
// src/pack/pack.ts
|
|
7741
|
-
import { mkdir as mkdir5, readFile as readFile8, writeFile as writeFile7 } from "node:fs/promises";
|
|
7742
|
-
import { dirname, extname as extname3 } from "node:path";
|
|
7743
|
-
import { unzipSync, zipSync } from "fflate";
|
|
7744
|
-
var MTIME = Date.UTC(1980, 0, 2, 12);
|
|
7745
|
-
var STORED = /* @__PURE__ */ new Set([
|
|
7746
|
-
".png",
|
|
7747
|
-
".jpg",
|
|
7748
|
-
".jpeg",
|
|
7749
|
-
".gif",
|
|
7750
|
-
".webp",
|
|
7751
|
-
".avif",
|
|
7752
|
-
".mp3",
|
|
7753
|
-
".m4a",
|
|
7754
|
-
".mp4",
|
|
7755
|
-
".webm",
|
|
7756
|
-
".mov",
|
|
7757
|
-
".ogg",
|
|
7758
|
-
".opus",
|
|
7759
|
-
".woff",
|
|
7760
|
-
".woff2"
|
|
7761
|
-
]);
|
|
7762
|
-
async function writePack(pack2, files, out) {
|
|
7763
|
-
const manifest = packSchema.parse(pack2);
|
|
7764
|
-
for (const m of manifest.media) {
|
|
7765
|
-
if (m.policy !== "bake") continue;
|
|
7766
|
-
if (!m.path) throw new Error(`media "${m.id}" is baked but has no path`);
|
|
7767
|
-
if (!(m.path in files)) throw new Error(`media "${m.id}" is baked but ${m.path} was not given`);
|
|
7768
|
-
}
|
|
7769
|
-
const entries = {
|
|
7770
|
-
"deck.json": new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}
|
|
7771
|
-
`)
|
|
7772
|
-
};
|
|
7773
|
-
for (const path2 of Object.keys(files).sort()) {
|
|
7774
|
-
const bytes = files[path2];
|
|
7775
|
-
if (!bytes) continue;
|
|
7776
|
-
if (path2 === "deck.json") throw new Error("deck.json is written from the manifest, not passed");
|
|
7777
|
-
check(path2);
|
|
7778
|
-
entries[path2] = STORED.has(extname3(path2).toLowerCase()) ? [bytes, { level: 0 }] : bytes;
|
|
7779
|
-
}
|
|
7780
|
-
const zip = zipSync(entries, { mtime: MTIME });
|
|
7781
|
-
await mkdir5(dirname(out), { recursive: true });
|
|
7782
|
-
await writeFile7(out, zip);
|
|
7783
|
-
return zip.length;
|
|
7784
|
-
}
|
|
7785
|
-
async function readPack(path2) {
|
|
7786
|
-
return openPack(new Uint8Array(await readFile8(path2)), path2);
|
|
8526
|
+
async function readPack(path2) {
|
|
8527
|
+
return openPack(new Uint8Array(await readFile8(path2)), path2);
|
|
7787
8528
|
}
|
|
7788
8529
|
function openPack(bytes, label = "pack") {
|
|
7789
8530
|
if (bytes.length < 4 || bytes[0] !== 80 || bytes[1] !== 75)
|
|
@@ -7936,7 +8677,7 @@ function number(flag, value) {
|
|
|
7936
8677
|
|
|
7937
8678
|
// src/render/capture.ts
|
|
7938
8679
|
import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile8 } from "node:fs/promises";
|
|
7939
|
-
import { createRequire } from "node:module";
|
|
8680
|
+
import { createRequire as createRequire2 } from "node:module";
|
|
7940
8681
|
import { homedir as homedir2 } from "node:os";
|
|
7941
8682
|
import { join as join8 } from "node:path";
|
|
7942
8683
|
import { pathToFileURL } from "node:url";
|
|
@@ -7945,15 +8686,15 @@ async function chromePath(need = "open the deck with") {
|
|
|
7945
8686
|
if (explicit) return explicit;
|
|
7946
8687
|
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
7947
8688
|
const cacheDir = process.env.PUPPETEER_CACHE_DIR || join8(homedir2(), ".cache", "puppeteer");
|
|
7948
|
-
const
|
|
7949
|
-
const found =
|
|
8689
|
+
const installed2 = await getInstalledBrowsers({ cacheDir }).catch(() => []);
|
|
8690
|
+
const found = installed2.find((b) => b.browser === "chrome-headless-shell") ?? installed2.find((b) => b.browser === "chrome");
|
|
7950
8691
|
if (found) return found.executablePath;
|
|
7951
8692
|
throw new Error(
|
|
7952
8693
|
`no Chrome to ${need} \u2014 run \`npx puppeteer browsers install chrome\`, or set DECKSMITH_CHROME to a Chrome binary.`
|
|
7953
8694
|
);
|
|
7954
8695
|
}
|
|
7955
8696
|
function runtimePath() {
|
|
7956
|
-
return
|
|
8697
|
+
return createRequire2(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
|
|
7957
8698
|
}
|
|
7958
8699
|
function renderSeek(t2) {
|
|
7959
8700
|
const player = window.__player;
|
|
@@ -8128,7 +8869,7 @@ function readFragments(html) {
|
|
|
8128
8869
|
return out;
|
|
8129
8870
|
}
|
|
8130
8871
|
function holdsFor(beat, source, format, theme, sid, speed) {
|
|
8131
|
-
const ctx = { source, format, theme, sid };
|
|
8872
|
+
const ctx = { source, format, theme, sid, start: 0 };
|
|
8132
8873
|
const { scene, open } = stageScene(emitScene(beat, ctx), speed);
|
|
8133
8874
|
return {
|
|
8134
8875
|
holds: [...new Set(scene.holds.filter((h) => Number.isFinite(h) && h > 0))].sort(
|
|
@@ -8152,15 +8893,15 @@ function assertHoldsAgree(scenes, fragments) {
|
|
|
8152
8893
|
function place(scenes, spoken) {
|
|
8153
8894
|
const out = [];
|
|
8154
8895
|
for (const scene of scenes) {
|
|
8155
|
-
const
|
|
8156
|
-
if (
|
|
8896
|
+
const segments2 = [...spoken[scene.id] ?? []].sort((a, b) => a.stop - b.stop);
|
|
8897
|
+
if (segments2.length === 0) continue;
|
|
8157
8898
|
if (scene.holds.length === 0) {
|
|
8158
8899
|
throw new Error(
|
|
8159
8900
|
`${scene.id}: narrated but has no hold to speak at. Re-run \`narrate\` for this format.`
|
|
8160
8901
|
);
|
|
8161
8902
|
}
|
|
8162
|
-
const { starts } = speechPlan(scene.open, scene.holds,
|
|
8163
|
-
for (const [i, segment] of
|
|
8903
|
+
const { starts } = speechPlan(scene.open, scene.holds, segments2);
|
|
8904
|
+
for (const [i, segment] of segments2.entries()) {
|
|
8164
8905
|
const index = Math.min(segment.stop, scene.holds.length - 1);
|
|
8165
8906
|
out.push({
|
|
8166
8907
|
id: `${scene.id}.${segment.stop}`,
|
|
@@ -8176,9 +8917,9 @@ function place(scenes, spoken) {
|
|
|
8176
8917
|
}
|
|
8177
8918
|
return out;
|
|
8178
8919
|
}
|
|
8179
|
-
function assertFits(scenes,
|
|
8920
|
+
function assertFits(scenes, segments2) {
|
|
8180
8921
|
for (const scene of scenes) {
|
|
8181
|
-
const mine =
|
|
8922
|
+
const mine = segments2.filter((s) => s.scene === scene.id);
|
|
8182
8923
|
if (mine.length === 0) continue;
|
|
8183
8924
|
const spoken = mine.reduce((sum, s) => sum + s.duration, 0);
|
|
8184
8925
|
const need = scene.open + spoken;
|
|
@@ -8222,8 +8963,8 @@ function planTiming(input) {
|
|
|
8222
8963
|
const staging = holdsFor(beat, source, format, theme, scene.id, speed);
|
|
8223
8964
|
scene.holds = staging.holds;
|
|
8224
8965
|
scene.open = staging.open;
|
|
8225
|
-
const
|
|
8226
|
-
if (
|
|
8966
|
+
const segments3 = narration?.beats[beat.id];
|
|
8967
|
+
if (segments3?.length) spoken[scene.id] = segments3;
|
|
8227
8968
|
});
|
|
8228
8969
|
const fragments = readFragments(composition);
|
|
8229
8970
|
if (fragments) {
|
|
@@ -8232,18 +8973,18 @@ function planTiming(input) {
|
|
|
8232
8973
|
fragments
|
|
8233
8974
|
);
|
|
8234
8975
|
}
|
|
8235
|
-
const
|
|
8236
|
-
assertFits(scenes,
|
|
8976
|
+
const segments2 = place(scenes, spoken);
|
|
8977
|
+
assertFits(scenes, segments2);
|
|
8237
8978
|
return {
|
|
8238
8979
|
version: 1,
|
|
8239
8980
|
width: format.width,
|
|
8240
8981
|
height: format.height,
|
|
8241
8982
|
duration: readDuration(composition),
|
|
8242
8983
|
lang: storyboard.lang,
|
|
8243
|
-
audioDir:
|
|
8244
|
-
voice:
|
|
8984
|
+
audioDir: segments2.length > 0 ? narration?.dir ?? "" : "",
|
|
8985
|
+
voice: segments2.length > 0 ? narration?.voice ?? "" : "",
|
|
8245
8986
|
scenes,
|
|
8246
|
-
segments
|
|
8987
|
+
segments: segments2
|
|
8247
8988
|
};
|
|
8248
8989
|
}
|
|
8249
8990
|
function framePlan(timing, fps) {
|
|
@@ -8255,12 +8996,12 @@ function framePlan(timing, fps) {
|
|
|
8255
8996
|
for (const scene of timing.scenes) {
|
|
8256
8997
|
const first = f(scene.start);
|
|
8257
8998
|
const last = f(scene.start + scene.duration);
|
|
8258
|
-
const
|
|
8259
|
-
if (
|
|
8999
|
+
const segments2 = timing.segments.filter((s) => s.scene === scene.id).sort((a, b) => a.hold - b.hold || a.stop - b.stop);
|
|
9000
|
+
if (segments2.length === 0) {
|
|
8260
9001
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
8261
9002
|
continue;
|
|
8262
9003
|
}
|
|
8263
|
-
const tail3 =
|
|
9004
|
+
const tail3 = segments2[segments2.length - 1];
|
|
8264
9005
|
const silent = f(tail3.start + tail3.duration);
|
|
8265
9006
|
if (silent > last) {
|
|
8266
9007
|
throw new Error(
|
|
@@ -8268,7 +9009,7 @@ function framePlan(timing, fps) {
|
|
|
8268
9009
|
);
|
|
8269
9010
|
}
|
|
8270
9011
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
8271
|
-
for (const segment of
|
|
9012
|
+
for (const segment of segments2) {
|
|
8272
9013
|
const at = f(segment.start);
|
|
8273
9014
|
audio.push({
|
|
8274
9015
|
id: segment.id,
|
|
@@ -8395,7 +9136,7 @@ ${bands}
|
|
|
8395
9136
|
</div>
|
|
8396
9137
|
`;
|
|
8397
9138
|
}
|
|
8398
|
-
function
|
|
9139
|
+
function measure3(count) {
|
|
8399
9140
|
const doc = document;
|
|
8400
9141
|
const probe2 = doc.getElementById("c0");
|
|
8401
9142
|
if (probe2) {
|
|
@@ -8446,7 +9187,7 @@ async function renderCaptions(cues, style, deck, work) {
|
|
|
8446
9187
|
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
8447
9188
|
await tab.goto(pathToFileURL2(page).href, { waitUntil: "load" });
|
|
8448
9189
|
await tab.evaluate(() => document.fonts.ready);
|
|
8449
|
-
const rects = await tab.evaluate(
|
|
9190
|
+
const rects = await tab.evaluate(measure3, cues.length);
|
|
8450
9191
|
const box = union(rects);
|
|
8451
9192
|
const files = [];
|
|
8452
9193
|
for (const [i, cue] of cues.entries()) {
|
|
@@ -8510,7 +9251,7 @@ ${tail3}`);
|
|
|
8510
9251
|
}
|
|
8511
9252
|
}
|
|
8512
9253
|
function runLive(file, args) {
|
|
8513
|
-
return new Promise((
|
|
9254
|
+
return new Promise((resolve6, reject) => {
|
|
8514
9255
|
const child = spawn3(file, args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
8515
9256
|
child.on("error", (err) => {
|
|
8516
9257
|
reject(
|
|
@@ -8518,7 +9259,7 @@ function runLive(file, args) {
|
|
|
8518
9259
|
);
|
|
8519
9260
|
});
|
|
8520
9261
|
child.on("close", (code, signal) => {
|
|
8521
|
-
if (code === 0)
|
|
9262
|
+
if (code === 0) resolve6();
|
|
8522
9263
|
else if (signal) {
|
|
8523
9264
|
reject(
|
|
8524
9265
|
new Error(
|
|
@@ -8711,148 +9452,1416 @@ async function render(opts) {
|
|
|
8711
9452
|
"ffmpeg",
|
|
8712
9453
|
respeedArgs(out, playback, tempoChain(playback), shot.fps, muxed.hasAudio, fast)
|
|
8713
9454
|
);
|
|
8714
|
-
await rename2(fast, out);
|
|
9455
|
+
await rename2(fast, out);
|
|
9456
|
+
}
|
|
9457
|
+
const final = await probe(out);
|
|
9458
|
+
if (plan.audio.length > 0 && !final.hasAudio) {
|
|
9459
|
+
throw new Error(`${out} came out with no audio stream. The mux silently dropped it.`);
|
|
9460
|
+
}
|
|
9461
|
+
return {
|
|
9462
|
+
out,
|
|
9463
|
+
...srt ? { srt: srtPath } : {},
|
|
9464
|
+
seconds: final.seconds,
|
|
9465
|
+
frames: final.frames,
|
|
9466
|
+
segments: plan.audio.length,
|
|
9467
|
+
burned: burn,
|
|
9468
|
+
playback,
|
|
9469
|
+
// The cues as written, not `plan.cues` — at `playback > 1` these are the
|
|
9470
|
+
// scaled ones, so the number the caller prints is the rate on the file it
|
|
9471
|
+
// just got rather than the rate on a timeline that no longer exists.
|
|
9472
|
+
captionCps: p95CueRate(cues)
|
|
9473
|
+
};
|
|
9474
|
+
} finally {
|
|
9475
|
+
if (!opts.keep) await rm5(work, { recursive: true, force: true });
|
|
9476
|
+
}
|
|
9477
|
+
}
|
|
9478
|
+
async function readTiming(deck) {
|
|
9479
|
+
const path2 = join10(deck, TIMING_FILE);
|
|
9480
|
+
const text2 = await readFile11(path2, "utf8").catch(() => {
|
|
9481
|
+
throw new Error(
|
|
9482
|
+
`${path2} is missing. \`render\` needs the timing manifest \`build\` writes; rebuild the deck.`
|
|
9483
|
+
);
|
|
9484
|
+
});
|
|
9485
|
+
const timing = JSON.parse(text2);
|
|
9486
|
+
if (timing.version !== 1) throw new Error(`${path2} is version ${timing.version}; expected 1.`);
|
|
9487
|
+
return timing;
|
|
9488
|
+
}
|
|
9489
|
+
function assertCapture(timing, shot) {
|
|
9490
|
+
if (shot.width !== timing.width || shot.height !== timing.height) {
|
|
9491
|
+
throw new Error(
|
|
9492
|
+
`the capture is ${shot.width}\xD7${shot.height} but ${TIMING_FILE} describes a ${timing.width}\xD7${timing.height} deck.`
|
|
9493
|
+
);
|
|
9494
|
+
}
|
|
9495
|
+
if (Math.abs(shot.seconds - timing.duration) > 0.5) {
|
|
9496
|
+
throw new Error(
|
|
9497
|
+
`the capture is ${shot.seconds.toFixed(2)}s but ${TIMING_FILE} describes a ${timing.duration.toFixed(2)}s deck. One of them is stale.`
|
|
9498
|
+
);
|
|
9499
|
+
}
|
|
9500
|
+
}
|
|
9501
|
+
function scaleCue(cue, factor) {
|
|
9502
|
+
const at = (t2) => Math.round(t2 / factor * 1e3) / 1e3;
|
|
9503
|
+
return { ...cue, start: at(cue.start), end: at(cue.end) };
|
|
9504
|
+
}
|
|
9505
|
+
async function capture(deck, out, opts, log) {
|
|
9506
|
+
const args = ["hyperframes", "render", deck, "-o", out];
|
|
9507
|
+
if (opts.fps) args.push("--fps", String(opts.fps));
|
|
9508
|
+
if (opts.quality) args.push("--quality", opts.quality);
|
|
9509
|
+
if (opts.workers) args.push("--workers", opts.workers);
|
|
9510
|
+
args.push("--protocol-timeout", String(opts.protocolTimeoutMs ?? 9e5));
|
|
9511
|
+
log("render: capturing the composition \u2014 this is the long part");
|
|
9512
|
+
await runLive("npx", args);
|
|
9513
|
+
return out;
|
|
9514
|
+
}
|
|
9515
|
+
async function retime(raw2, plan, work, log) {
|
|
9516
|
+
if (plan.pieces.every((p) => p.freeze === 0)) return raw2;
|
|
9517
|
+
const list = [];
|
|
9518
|
+
for (const [i, piece] of plan.pieces.entries()) {
|
|
9519
|
+
const file = join10(work, `p${String(i).padStart(4, "0")}.ts`);
|
|
9520
|
+
await runTool("ffmpeg", pieceArgs(raw2, piece.from, piece.motion, piece.freeze, plan.fps, file));
|
|
9521
|
+
list.push(file);
|
|
9522
|
+
if ((i + 1) % 10 === 0 || i === plan.pieces.length - 1) {
|
|
9523
|
+
log(`render: retimed ${i + 1}/${plan.pieces.length} pieces`);
|
|
9524
|
+
}
|
|
9525
|
+
}
|
|
9526
|
+
const listFile = join10(work, "pieces.txt");
|
|
9527
|
+
await writeFile10(
|
|
9528
|
+
listFile,
|
|
9529
|
+
`${list.map((f) => `file '${f.replace(/'/g, "'\\''")}'`).join("\n")}
|
|
9530
|
+
`
|
|
9531
|
+
);
|
|
9532
|
+
const joined = join10(work, "retimed.mp4");
|
|
9533
|
+
await runTool("ffmpeg", [
|
|
9534
|
+
"-y",
|
|
9535
|
+
"-hide_banner",
|
|
9536
|
+
"-loglevel",
|
|
9537
|
+
"error",
|
|
9538
|
+
"-f",
|
|
9539
|
+
"concat",
|
|
9540
|
+
"-safe",
|
|
9541
|
+
"0",
|
|
9542
|
+
"-i",
|
|
9543
|
+
listFile,
|
|
9544
|
+
"-c",
|
|
9545
|
+
"copy",
|
|
9546
|
+
"-movflags",
|
|
9547
|
+
"+faststart",
|
|
9548
|
+
joined
|
|
9549
|
+
]);
|
|
9550
|
+
return joined;
|
|
9551
|
+
}
|
|
9552
|
+
async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
|
|
9553
|
+
const inputs = plan.audio.map((a) => ({
|
|
9554
|
+
file: join10(deck, timing.audioDir, a.audio),
|
|
9555
|
+
delayMs: a.delayMs
|
|
9556
|
+
}));
|
|
9557
|
+
const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
|
|
9558
|
+
const graph = [];
|
|
9559
|
+
const band = burnCues ? await renderCaptions(
|
|
9560
|
+
burnCues,
|
|
9561
|
+
burnStyle(timing.width, timing.height, familyFor(timing.lang) ?? "Arial"),
|
|
9562
|
+
deck,
|
|
9563
|
+
work
|
|
9564
|
+
) : void 0;
|
|
9565
|
+
if (band) {
|
|
9566
|
+
log(`render: drew ${band.files.length} caption band(s), ${band.width}\xD7${band.height}px`);
|
|
9567
|
+
graph.push(overlayGraph(burnCues, band));
|
|
9568
|
+
}
|
|
9569
|
+
if (band) args.push(...overlayInputs(band));
|
|
9570
|
+
for (const input of inputs) args.push("-i", input.file);
|
|
9571
|
+
if (inputs.length > 0) {
|
|
9572
|
+
graph.push(audioGraph(inputs, plan.frames / fps, 1 + (band?.files.length ?? 0)));
|
|
9573
|
+
}
|
|
9574
|
+
if (graph.length > 0) {
|
|
9575
|
+
const script = join10(work, "mux.filter");
|
|
9576
|
+
await writeFile10(script, `${graph.join(";\n")}
|
|
9577
|
+
`);
|
|
9578
|
+
args.push("-filter_complex_script", script);
|
|
9579
|
+
}
|
|
9580
|
+
args.push("-map", burnCues ? "[vout]" : "0:v");
|
|
9581
|
+
if (inputs.length > 0) args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "192k");
|
|
9582
|
+
if (burnCues) {
|
|
9583
|
+
args.push("-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p");
|
|
9584
|
+
} else {
|
|
9585
|
+
args.push("-c:v", "copy");
|
|
9586
|
+
}
|
|
9587
|
+
args.push("-movflags", "+faststart", out);
|
|
9588
|
+
log(
|
|
9589
|
+
`render: muxing ${inputs.length} segment(s)${burnCues ? " and burning in the captions" : ""} \u2192 ${out}`
|
|
9590
|
+
);
|
|
9591
|
+
await mkdir8(dirname3(out), { recursive: true });
|
|
9592
|
+
await runTool("ffmpeg", args, { cwd: work });
|
|
9593
|
+
}
|
|
9594
|
+
|
|
9595
|
+
// src/source/harvest.ts
|
|
9596
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
9597
|
+
import { copyFile, mkdir as mkdir9, rm as rm7, stat, writeFile as writeFile11 } from "node:fs/promises";
|
|
9598
|
+
import { basename as basename3, join as join11, resolve as resolve4 } from "node:path";
|
|
9599
|
+
|
|
9600
|
+
// src/source/readability.ts
|
|
9601
|
+
var CONTENT_MARKER = "data-ds-content";
|
|
9602
|
+
function readContentRegion() {
|
|
9603
|
+
const MARKER = "data-ds-content";
|
|
9604
|
+
const MIN_TEXT = 250;
|
|
9605
|
+
const MAX_ANCESTORS = 5;
|
|
9606
|
+
const SIBLING_FRACTION = 0.2;
|
|
9607
|
+
const MIN_SIBLING_SCORE = 10;
|
|
9608
|
+
const MIN_PARAGRAPH = 25;
|
|
9609
|
+
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;
|
|
9610
|
+
const MAYBE = /and|article|body|column|content|main|shadow|story/i;
|
|
9611
|
+
const POSITIVE = /article|body|content|entry|hentry|h-entry|main|page|post|story|text|blog/i;
|
|
9612
|
+
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;
|
|
9613
|
+
const CHROME_ROLES = /* @__PURE__ */ new Set([
|
|
9614
|
+
"alert",
|
|
9615
|
+
"alertdialog",
|
|
9616
|
+
"banner",
|
|
9617
|
+
"complementary",
|
|
9618
|
+
"contentinfo",
|
|
9619
|
+
"dialog",
|
|
9620
|
+
"menu",
|
|
9621
|
+
"menubar",
|
|
9622
|
+
"navigation",
|
|
9623
|
+
"search",
|
|
9624
|
+
"toolbar",
|
|
9625
|
+
"tooltip"
|
|
9626
|
+
]);
|
|
9627
|
+
const CHROME_TAGS = /* @__PURE__ */ new Set([
|
|
9628
|
+
"nav",
|
|
9629
|
+
"aside",
|
|
9630
|
+
"footer",
|
|
9631
|
+
"script",
|
|
9632
|
+
"style",
|
|
9633
|
+
"noscript",
|
|
9634
|
+
"form",
|
|
9635
|
+
"svg",
|
|
9636
|
+
"canvas",
|
|
9637
|
+
"template",
|
|
9638
|
+
"button",
|
|
9639
|
+
"select",
|
|
9640
|
+
"textarea"
|
|
9641
|
+
]);
|
|
9642
|
+
const SCORED = "p, td, pre, section, h2, h3, h4, h5, h6";
|
|
9643
|
+
function textOf2(node) {
|
|
9644
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
9645
|
+
}
|
|
9646
|
+
function labelOf(el) {
|
|
9647
|
+
return `${el.getAttribute("class") ?? ""} ${el.getAttribute("id") ?? ""}`;
|
|
9648
|
+
}
|
|
9649
|
+
function nameOf(el) {
|
|
9650
|
+
const id2 = el.getAttribute("id");
|
|
9651
|
+
const cls = (el.getAttribute("class") ?? "").trim().split(/\s+/)[0];
|
|
9652
|
+
return el.tagName.toLowerCase() + (id2 ? `#${id2}` : "") + (cls ? `.${cls}` : "");
|
|
9653
|
+
}
|
|
9654
|
+
function pathOf(el) {
|
|
9655
|
+
const parts = [];
|
|
9656
|
+
let node = el;
|
|
9657
|
+
while (node && parts.length < 3) {
|
|
9658
|
+
parts.unshift(nameOf(node));
|
|
9659
|
+
node = node.parentElement;
|
|
9660
|
+
}
|
|
9661
|
+
return parts.join(" > ");
|
|
9662
|
+
}
|
|
9663
|
+
function round7(n3) {
|
|
9664
|
+
return Math.round(n3 * 1e3) / 1e3;
|
|
9665
|
+
}
|
|
9666
|
+
function linkDensity(el) {
|
|
9667
|
+
const total = textOf2(el).length;
|
|
9668
|
+
if (total === 0) return 0;
|
|
9669
|
+
let inLinks = 0;
|
|
9670
|
+
for (const a of el.querySelectorAll("a")) {
|
|
9671
|
+
const href = a.getAttribute("href") ?? "";
|
|
9672
|
+
inLinks += textOf2(a).length * (href.startsWith("#") ? 0.3 : 1);
|
|
9673
|
+
}
|
|
9674
|
+
return inLinks / total;
|
|
9675
|
+
}
|
|
9676
|
+
function classWeight(el) {
|
|
9677
|
+
let weight = 0;
|
|
9678
|
+
for (const label of [el.getAttribute("class") ?? "", el.getAttribute("id") ?? ""]) {
|
|
9679
|
+
if (label === "") continue;
|
|
9680
|
+
if (NEGATIVE.test(label)) weight -= 25;
|
|
9681
|
+
if (POSITIVE.test(label)) weight += 25;
|
|
9682
|
+
}
|
|
9683
|
+
return weight;
|
|
9684
|
+
}
|
|
9685
|
+
function tagBonus(tag) {
|
|
9686
|
+
if (tag === "div") return 5;
|
|
9687
|
+
if (tag === "pre" || tag === "td" || tag === "blockquote") return 3;
|
|
9688
|
+
if (tag === "th" || /^h[1-6]$/.test(tag)) return -5;
|
|
9689
|
+
if (/^(address|ol|ul|dl|dd|dt|li|form)$/.test(tag)) return -3;
|
|
9690
|
+
return 0;
|
|
9691
|
+
}
|
|
9692
|
+
const scores = /* @__PURE__ */ new Map();
|
|
9693
|
+
function scoreOf(el) {
|
|
9694
|
+
const at = scores.get(el);
|
|
9695
|
+
if (at !== void 0) return at;
|
|
9696
|
+
const start = tagBonus(el.tagName.toLowerCase()) + classWeight(el);
|
|
9697
|
+
scores.set(el, start);
|
|
9698
|
+
return start;
|
|
9699
|
+
}
|
|
9700
|
+
const removed = [];
|
|
9701
|
+
function strip(el) {
|
|
9702
|
+
const parent = el.parentElement;
|
|
9703
|
+
if (!parent) return;
|
|
9704
|
+
removed.push({ node: el, parent, next: el.nextSibling });
|
|
9705
|
+
el.remove();
|
|
9706
|
+
}
|
|
9707
|
+
function restore() {
|
|
9708
|
+
for (let i = removed.length - 1; i >= 0; i--) {
|
|
9709
|
+
const at = removed[i];
|
|
9710
|
+
if (at) at.parent.insertBefore(at.node, at.next);
|
|
9711
|
+
}
|
|
9712
|
+
removed.length = 0;
|
|
9713
|
+
}
|
|
9714
|
+
function declined(reason3, candidates2) {
|
|
9715
|
+
restore();
|
|
9716
|
+
return {
|
|
9717
|
+
marked: false,
|
|
9718
|
+
reason: reason3,
|
|
9719
|
+
candidates: candidates2,
|
|
9720
|
+
merged: 0,
|
|
9721
|
+
stripped: 0,
|
|
9722
|
+
text: 0,
|
|
9723
|
+
mediaDropped: 0
|
|
9724
|
+
};
|
|
9725
|
+
}
|
|
9726
|
+
try {
|
|
9727
|
+
if (!document.body) {
|
|
9728
|
+
return declined("the document has no <body>, so there is no region to choose", []);
|
|
9729
|
+
}
|
|
9730
|
+
const mediaBefore = document.querySelectorAll("video, iframe").length;
|
|
9731
|
+
for (const el of [...document.body.querySelectorAll("*")]) {
|
|
9732
|
+
if (!el.isConnected) continue;
|
|
9733
|
+
const tag = el.tagName.toLowerCase();
|
|
9734
|
+
if (CHROME_TAGS.has(tag)) {
|
|
9735
|
+
strip(el);
|
|
9736
|
+
continue;
|
|
9737
|
+
}
|
|
9738
|
+
if (tag === "header" && el.parentElement?.closest("article, main, section") == null) {
|
|
9739
|
+
strip(el);
|
|
9740
|
+
continue;
|
|
9741
|
+
}
|
|
9742
|
+
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
|
|
9743
|
+
strip(el);
|
|
9744
|
+
continue;
|
|
9745
|
+
}
|
|
9746
|
+
if (CHROME_ROLES.has((el.getAttribute("role") ?? "").toLowerCase())) {
|
|
9747
|
+
strip(el);
|
|
9748
|
+
continue;
|
|
9749
|
+
}
|
|
9750
|
+
const label = labelOf(el);
|
|
9751
|
+
if (UNLIKELY.test(label) && !MAYBE.test(label) && !el.querySelector("main, article")) {
|
|
9752
|
+
strip(el);
|
|
9753
|
+
}
|
|
9754
|
+
}
|
|
9755
|
+
for (const el of document.body.querySelectorAll(SCORED)) {
|
|
9756
|
+
const text3 = textOf2(el);
|
|
9757
|
+
if (text3.length < MIN_PARAGRAPH) continue;
|
|
9758
|
+
const base = 1 + (text3.split(",").length - 1) + Math.min(Math.floor(text3.length / 100), 3);
|
|
9759
|
+
let node = el.parentElement;
|
|
9760
|
+
let level = 0;
|
|
9761
|
+
while (node && node !== document.documentElement && level < MAX_ANCESTORS) {
|
|
9762
|
+
const divider = level === 0 ? 1 : level === 1 ? 2 : level * 3;
|
|
9763
|
+
scores.set(node, scoreOf(node) + base / divider);
|
|
9764
|
+
node = node.parentElement;
|
|
9765
|
+
level++;
|
|
9766
|
+
}
|
|
9767
|
+
}
|
|
9768
|
+
const ranked = [];
|
|
9769
|
+
for (const [el, content] of scores) {
|
|
9770
|
+
const density = linkDensity(el);
|
|
9771
|
+
ranked.push({
|
|
9772
|
+
el,
|
|
9773
|
+
report: {
|
|
9774
|
+
path: pathOf(el),
|
|
9775
|
+
content: round7(content),
|
|
9776
|
+
linkDensity: round7(density),
|
|
9777
|
+
// THE WHOLE POINT OF THE PASS. A rail of related headlines and an
|
|
9778
|
+
// article of the same length hold the same number of characters; only
|
|
9779
|
+
// this term separates them, and it is why the discount is a
|
|
9780
|
+
// multiplier rather than a subtraction — a candidate that is entirely
|
|
9781
|
+
// links scores zero however long it is.
|
|
9782
|
+
score: round7(content * (1 - density)),
|
|
9783
|
+
text: textOf2(el).length
|
|
9784
|
+
}
|
|
9785
|
+
});
|
|
9786
|
+
}
|
|
9787
|
+
ranked.sort((a, b) => b.report.score - a.report.score);
|
|
9788
|
+
const report2 = ranked.slice(0, 5).map((r) => r.report);
|
|
9789
|
+
let top = ranked[0];
|
|
9790
|
+
if (!top) {
|
|
9791
|
+
return declined(
|
|
9792
|
+
`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.`,
|
|
9793
|
+
report2
|
|
9794
|
+
);
|
|
9795
|
+
}
|
|
9796
|
+
const byElement = new Map(ranked.map((r) => [r.el, r]));
|
|
9797
|
+
const floor = top.report.score / 3;
|
|
9798
|
+
let last = top.report.score;
|
|
9799
|
+
let up = top.el.parentElement;
|
|
9800
|
+
while (up && up !== document.body && up !== document.documentElement) {
|
|
9801
|
+
const here = byElement.get(up);
|
|
9802
|
+
if (here) {
|
|
9803
|
+
if (here.report.score < floor) break;
|
|
9804
|
+
if (here.report.score > last) {
|
|
9805
|
+
top = here;
|
|
9806
|
+
break;
|
|
9807
|
+
}
|
|
9808
|
+
last = here.report.score;
|
|
9809
|
+
}
|
|
9810
|
+
up = up.parentElement;
|
|
9811
|
+
}
|
|
9812
|
+
if (top.el === document.body) {
|
|
9813
|
+
const text3 = textOf2(document.body).length;
|
|
9814
|
+
if (text3 < MIN_TEXT) {
|
|
9815
|
+
return declined(
|
|
9816
|
+
`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`,
|
|
9817
|
+
report2
|
|
9818
|
+
);
|
|
9819
|
+
}
|
|
9820
|
+
document.body.setAttribute(MARKER, "");
|
|
9821
|
+
return {
|
|
9822
|
+
marked: true,
|
|
9823
|
+
reason: `chose <body>: nothing narrower than the page itself scored, ${text3} characters`,
|
|
9824
|
+
candidates: report2,
|
|
9825
|
+
merged: 0,
|
|
9826
|
+
stripped: removed.length,
|
|
9827
|
+
text: text3,
|
|
9828
|
+
mediaDropped: mediaBefore - document.body.querySelectorAll("video, iframe").length
|
|
9829
|
+
};
|
|
9830
|
+
}
|
|
9831
|
+
const parent = top.el.parentElement;
|
|
9832
|
+
if (!parent) {
|
|
9833
|
+
return declined("the winning candidate is not in the document any more", report2);
|
|
9834
|
+
}
|
|
9835
|
+
const threshold = Math.max(MIN_SIBLING_SCORE, top.report.score * SIBLING_FRACTION);
|
|
9836
|
+
const topClass = top.el.getAttribute("class") ?? "";
|
|
9837
|
+
const keep = [];
|
|
9838
|
+
for (const sib of [...parent.children]) {
|
|
9839
|
+
if (sib === top.el) {
|
|
9840
|
+
keep.push(sib);
|
|
9841
|
+
continue;
|
|
9842
|
+
}
|
|
9843
|
+
const twin = topClass !== "" && sib.getAttribute("class") === topClass;
|
|
9844
|
+
const bonus = twin ? top.report.score * SIBLING_FRACTION : 0;
|
|
9845
|
+
const here = byElement.get(sib);
|
|
9846
|
+
if (here && here.report.score + bonus >= threshold) {
|
|
9847
|
+
keep.push(sib);
|
|
9848
|
+
continue;
|
|
9849
|
+
}
|
|
9850
|
+
if (sib.tagName.toLowerCase() === "p") {
|
|
9851
|
+
const text3 = textOf2(sib);
|
|
9852
|
+
const density = linkDensity(sib);
|
|
9853
|
+
const long = text3.length > 80 && density < 0.25;
|
|
9854
|
+
const sentence = text3.length > 0 && text3.length <= 80 && density === 0 && /\.( |$)/.test(text3);
|
|
9855
|
+
if (long || sentence) keep.push(sib);
|
|
9856
|
+
}
|
|
9857
|
+
}
|
|
9858
|
+
const text2 = keep.reduce((n3, el) => n3 + textOf2(el).length, 0);
|
|
9859
|
+
if (text2 < MIN_TEXT) {
|
|
9860
|
+
return declined(
|
|
9861
|
+
`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`,
|
|
9862
|
+
report2
|
|
9863
|
+
);
|
|
9864
|
+
}
|
|
9865
|
+
let region;
|
|
9866
|
+
if (keep.length === 1 && keep[0]) {
|
|
9867
|
+
region = keep[0];
|
|
9868
|
+
} else {
|
|
9869
|
+
const box = document.createElement("div");
|
|
9870
|
+
for (const el of keep) box.appendChild(el);
|
|
9871
|
+
document.body.appendChild(box);
|
|
9872
|
+
region = box;
|
|
9873
|
+
}
|
|
9874
|
+
region.setAttribute(MARKER, "");
|
|
9875
|
+
return {
|
|
9876
|
+
marked: true,
|
|
9877
|
+
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` : ""),
|
|
9878
|
+
candidates: report2,
|
|
9879
|
+
merged: keep.length - 1,
|
|
9880
|
+
stripped: removed.length,
|
|
9881
|
+
text: textOf2(region).length,
|
|
9882
|
+
mediaDropped: mediaBefore - region.querySelectorAll("video, iframe").length
|
|
9883
|
+
};
|
|
9884
|
+
} catch (e) {
|
|
9885
|
+
return declined(
|
|
9886
|
+
`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`,
|
|
9887
|
+
[]
|
|
9888
|
+
);
|
|
9889
|
+
}
|
|
9890
|
+
}
|
|
9891
|
+
|
|
9892
|
+
// src/source/transcode.ts
|
|
9893
|
+
import { readFile as readFile12, rm as rm6 } from "node:fs/promises";
|
|
9894
|
+
import { basename as basename2 } from "node:path";
|
|
9895
|
+
var CLIP_EDGE_PX = 1280;
|
|
9896
|
+
var MAX_CLIP_SECONDS = 60;
|
|
9897
|
+
var TIMEOUT_MS = 6e5;
|
|
9898
|
+
function fitBox(width, height, maxEdgePx) {
|
|
9899
|
+
const scale = Math.min(1, maxEdgePx / width, maxEdgePx / height);
|
|
9900
|
+
return { width: even(width * scale), height: even(height * scale) };
|
|
9901
|
+
}
|
|
9902
|
+
function even(n3) {
|
|
9903
|
+
return Math.max(2, Math.floor(Math.round(n3) / 2) * 2);
|
|
9904
|
+
}
|
|
9905
|
+
function transcodeArgs(input, out, plan) {
|
|
9906
|
+
return [
|
|
9907
|
+
"-y",
|
|
9908
|
+
"-hide_banner",
|
|
9909
|
+
"-loglevel",
|
|
9910
|
+
"error",
|
|
9911
|
+
"-i",
|
|
9912
|
+
input,
|
|
9913
|
+
...plan.seconds === void 0 ? [] : ["-t", plan.seconds.toFixed(3)],
|
|
9914
|
+
"-an",
|
|
9915
|
+
"-map_metadata",
|
|
9916
|
+
"-1",
|
|
9917
|
+
"-vf",
|
|
9918
|
+
`scale=${plan.width}:${plan.height}`,
|
|
9919
|
+
"-c:v",
|
|
9920
|
+
"libvpx-vp9",
|
|
9921
|
+
"-crf",
|
|
9922
|
+
"32",
|
|
9923
|
+
"-b:v",
|
|
9924
|
+
"0",
|
|
9925
|
+
"-deadline",
|
|
9926
|
+
"good",
|
|
9927
|
+
"-cpu-used",
|
|
9928
|
+
"4",
|
|
9929
|
+
"-row-mt",
|
|
9930
|
+
"1",
|
|
9931
|
+
"-pix_fmt",
|
|
9932
|
+
"yuv420p",
|
|
9933
|
+
"-fflags",
|
|
9934
|
+
"+bitexact",
|
|
9935
|
+
"-flags:v",
|
|
9936
|
+
"+bitexact",
|
|
9937
|
+
out
|
|
9938
|
+
];
|
|
9939
|
+
}
|
|
9940
|
+
var probed = /* @__PURE__ */ new Map();
|
|
9941
|
+
function installed(file) {
|
|
9942
|
+
const asked = probed.get(file);
|
|
9943
|
+
if (asked !== void 0) return asked;
|
|
9944
|
+
const answer = runTool(file, ["-version"], { timeoutMs: 5e3 }).then(
|
|
9945
|
+
() => true,
|
|
9946
|
+
() => false
|
|
9947
|
+
);
|
|
9948
|
+
probed.set(file, answer);
|
|
9949
|
+
return answer;
|
|
9950
|
+
}
|
|
9951
|
+
async function transcode(input, out, opts = {}) {
|
|
9952
|
+
const file = opts.ffmpeg ?? "ffmpeg";
|
|
9953
|
+
const maxSeconds = opts.maxSeconds ?? MAX_CLIP_SECONDS;
|
|
9954
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
9955
|
+
const name = basename2(input);
|
|
9956
|
+
const source = videoSize(await readFile12(input));
|
|
9957
|
+
const box = fitBox(source.width, source.height, opts.maxEdgePx ?? CLIP_EDGE_PX);
|
|
9958
|
+
const kept = (why3) => ({
|
|
9959
|
+
path: input,
|
|
9960
|
+
width: source.width,
|
|
9961
|
+
height: source.height,
|
|
9962
|
+
seconds: source.seconds,
|
|
9963
|
+
transcoded: false,
|
|
9964
|
+
warnings: [`${name} was shipped as the page served it: ${why3}`]
|
|
9965
|
+
});
|
|
9966
|
+
if (!await installed(file)) {
|
|
9967
|
+
return kept(
|
|
9968
|
+
`${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.`
|
|
9969
|
+
);
|
|
9970
|
+
}
|
|
9971
|
+
const trim = source.seconds === void 0 || source.seconds > maxSeconds;
|
|
9972
|
+
const started = Date.now();
|
|
9973
|
+
try {
|
|
9974
|
+
await runTool(
|
|
9975
|
+
file,
|
|
9976
|
+
transcodeArgs(input, out, { ...box, ...trim ? { seconds: maxSeconds } : {} }),
|
|
9977
|
+
{ timeoutMs }
|
|
9978
|
+
);
|
|
9979
|
+
} catch (err) {
|
|
9980
|
+
await rm6(out, { force: true });
|
|
9981
|
+
const over = Date.now() - started >= timeoutMs;
|
|
9982
|
+
return kept(
|
|
9983
|
+
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)}`
|
|
9984
|
+
);
|
|
9985
|
+
}
|
|
9986
|
+
let measured;
|
|
9987
|
+
try {
|
|
9988
|
+
measured = videoSize(await readFile12(out));
|
|
9989
|
+
} catch (err) {
|
|
9990
|
+
await rm6(out, { force: true });
|
|
9991
|
+
return kept(
|
|
9992
|
+
`the webm ${file} wrote could not be measured \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
9993
|
+
);
|
|
9994
|
+
}
|
|
9995
|
+
const warnings = [];
|
|
9996
|
+
if (trim && measured.seconds !== void 0 && measured.seconds >= maxSeconds - 0.05) {
|
|
9997
|
+
warnings.push(
|
|
9998
|
+
`${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.`
|
|
9999
|
+
);
|
|
10000
|
+
}
|
|
10001
|
+
return {
|
|
10002
|
+
path: out,
|
|
10003
|
+
width: measured.width,
|
|
10004
|
+
height: measured.height,
|
|
10005
|
+
seconds: measured.seconds,
|
|
10006
|
+
transcoded: true,
|
|
10007
|
+
warnings
|
|
10008
|
+
};
|
|
10009
|
+
}
|
|
10010
|
+
|
|
10011
|
+
// src/source/harvest.ts
|
|
10012
|
+
var HTML_MAX_BYTES = 8 * 1024 * 1024;
|
|
10013
|
+
var ASSET_MAX_BYTES = 32 * 1024 * 1024;
|
|
10014
|
+
var TIMEOUT_MS2 = 2e4;
|
|
10015
|
+
var MAX_ASSETS = 40;
|
|
10016
|
+
var MAX_CLIPS = 4;
|
|
10017
|
+
var MAX_TOTAL_BYTES = 96 * 1024 * 1024;
|
|
10018
|
+
var MAX_WALL_MS = 18e4;
|
|
10019
|
+
var MIN_FIGURE_PX = 64;
|
|
10020
|
+
var HTML_TYPES = /^text\/html$|^application\/xhtml\+xml$/;
|
|
10021
|
+
async function harvest(url2, dir, opts = {}) {
|
|
10022
|
+
const timeoutMs = opts.timeoutMs ?? TIMEOUT_MS2;
|
|
10023
|
+
const warnings = [];
|
|
10024
|
+
const assets = resolve4(dir);
|
|
10025
|
+
await mkdir9(assets, { recursive: true });
|
|
10026
|
+
const page = await fetchGuarded(url2, {
|
|
10027
|
+
maxBytes: opts.maxBytes ?? HTML_MAX_BYTES,
|
|
10028
|
+
timeoutMs,
|
|
10029
|
+
accept: HTML_TYPES,
|
|
10030
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
10031
|
+
});
|
|
10032
|
+
const html = decodeHtml(page.bytes, page.contentType, url2, warnings);
|
|
10033
|
+
const { seen, pick } = await readInBrowser(html, timeoutMs);
|
|
10034
|
+
if (!pick.marked) {
|
|
10035
|
+
warnings.push(
|
|
10036
|
+
`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.`
|
|
10037
|
+
);
|
|
10038
|
+
} else if (pick.mediaDropped > 0) {
|
|
10039
|
+
warnings.push(
|
|
10040
|
+
`${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.`
|
|
10041
|
+
);
|
|
10042
|
+
}
|
|
10043
|
+
const base = absolute(seen.base, page.url) ?? page.url;
|
|
10044
|
+
const local = await localise(seen, base, assets, opts, timeoutMs, warnings);
|
|
10045
|
+
return {
|
|
10046
|
+
markdown: toMarkdown(titled(seen.title, local.blocks)),
|
|
10047
|
+
assets: local.assets,
|
|
10048
|
+
clips: local.clips,
|
|
10049
|
+
warnings,
|
|
10050
|
+
title: seen.title
|
|
10051
|
+
};
|
|
10052
|
+
}
|
|
10053
|
+
function titled(title2, blocks) {
|
|
10054
|
+
const first = blocks[0];
|
|
10055
|
+
const already = first?.kind === "heading" && first.depth === 1 && first.text.trim() === title2.trim();
|
|
10056
|
+
return title2 && !already ? [{ kind: "heading", depth: 1, text: title2 }, ...blocks] : [...blocks];
|
|
10057
|
+
}
|
|
10058
|
+
function decodeHtml(bytes, contentType, url2, warnings) {
|
|
10059
|
+
const declared = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType)?.[1];
|
|
10060
|
+
const head = bytes.toString("latin1", 0, Math.min(bytes.length, 4096));
|
|
10061
|
+
const meta = /<meta[^>]+charset\s*=\s*["']?([\w.:-]+)/i.exec(head)?.[1];
|
|
10062
|
+
const label = declared ?? meta;
|
|
10063
|
+
if (label === void 0) return bytes.toString("utf8");
|
|
10064
|
+
try {
|
|
10065
|
+
return new TextDecoder(label).decode(bytes);
|
|
10066
|
+
} catch {
|
|
10067
|
+
warnings.push(
|
|
10068
|
+
`${url2} 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.`
|
|
10069
|
+
);
|
|
10070
|
+
return bytes.toString("utf8");
|
|
10071
|
+
}
|
|
10072
|
+
}
|
|
10073
|
+
async function readInBrowser(html, timeoutMs) {
|
|
10074
|
+
const { default: puppeteer } = await import("puppeteer-core");
|
|
10075
|
+
const browser = await puppeteer.launch({
|
|
10076
|
+
executablePath: await chromePath("read the page with"),
|
|
10077
|
+
headless: true,
|
|
10078
|
+
// Chrome's own background traffic — variations, safe browsing, first-run
|
|
10079
|
+
// pings — never goes through page interception, so it is switched off here
|
|
10080
|
+
// rather than assumed absent. It is not an SSRF path, but "the browser makes
|
|
10081
|
+
// no requests" should be true of the whole process, not just of the tab.
|
|
10082
|
+
args: [
|
|
10083
|
+
"--disable-background-networking",
|
|
10084
|
+
"--disable-extensions",
|
|
10085
|
+
"--no-default-browser-check",
|
|
10086
|
+
"--no-first-run"
|
|
10087
|
+
]
|
|
10088
|
+
});
|
|
10089
|
+
try {
|
|
10090
|
+
const page = await browser.newPage();
|
|
10091
|
+
await page.setRequestInterception(true);
|
|
10092
|
+
page.on("request", (request) => {
|
|
10093
|
+
request.abort().catch(() => {
|
|
10094
|
+
});
|
|
10095
|
+
});
|
|
10096
|
+
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
10097
|
+
const pick = await page.evaluate(readContentRegion);
|
|
10098
|
+
return { seen: await page.evaluate(readDom, CONTENT_MARKER), pick };
|
|
10099
|
+
} finally {
|
|
10100
|
+
await browser.close().catch(() => {
|
|
10101
|
+
});
|
|
10102
|
+
}
|
|
10103
|
+
}
|
|
10104
|
+
function readDom(marker) {
|
|
10105
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
10106
|
+
"nav",
|
|
10107
|
+
"aside",
|
|
10108
|
+
"footer",
|
|
10109
|
+
"script",
|
|
10110
|
+
"style",
|
|
10111
|
+
"noscript",
|
|
10112
|
+
"form",
|
|
10113
|
+
"svg",
|
|
10114
|
+
"canvas",
|
|
10115
|
+
"template",
|
|
10116
|
+
"button",
|
|
10117
|
+
"select",
|
|
10118
|
+
"textarea"
|
|
10119
|
+
]);
|
|
10120
|
+
const BLOCKY = /* @__PURE__ */ new Set([
|
|
10121
|
+
"p",
|
|
10122
|
+
"div",
|
|
10123
|
+
"section",
|
|
10124
|
+
"article",
|
|
10125
|
+
"main",
|
|
10126
|
+
"ul",
|
|
10127
|
+
"ol",
|
|
10128
|
+
"table",
|
|
10129
|
+
"figure",
|
|
10130
|
+
"blockquote",
|
|
10131
|
+
"pre",
|
|
10132
|
+
"h1",
|
|
10133
|
+
"h2",
|
|
10134
|
+
"h3",
|
|
10135
|
+
"h4",
|
|
10136
|
+
"h5",
|
|
10137
|
+
"h6"
|
|
10138
|
+
]);
|
|
10139
|
+
const PLAYER = /(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|(?:player\.)?vimeo\.com\/|dailymotion\.com\/video\/|\.(?:mp4|webm|m4v|mov)(?:[?#]|$))/i;
|
|
10140
|
+
function words2(node) {
|
|
10141
|
+
return (node?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
10142
|
+
}
|
|
10143
|
+
function skipped(el) {
|
|
10144
|
+
return SKIP.has(el.tagName.toLowerCase()) || el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true";
|
|
10145
|
+
}
|
|
10146
|
+
function pickSrc(el) {
|
|
10147
|
+
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 }));
|
|
10148
|
+
let widest2 = candidates2[0];
|
|
10149
|
+
for (const candidate of candidates2) if (candidate.w >= (widest2?.w ?? -1)) widest2 = candidate;
|
|
10150
|
+
return widest2?.url ?? el.getAttribute("src") ?? el.getAttribute("data-src") ?? "";
|
|
10151
|
+
}
|
|
10152
|
+
function imageOf(el, caption) {
|
|
10153
|
+
const src = pickSrc(el);
|
|
10154
|
+
if (!src) return void 0;
|
|
10155
|
+
return { kind: "image", src, alt: words2(el.getAttributeNode("alt")), caption };
|
|
10156
|
+
}
|
|
10157
|
+
function videoOf(el, caption) {
|
|
10158
|
+
const source = el.querySelector("source");
|
|
10159
|
+
return {
|
|
10160
|
+
kind: "video",
|
|
10161
|
+
src: el.getAttribute("src") ?? source?.getAttribute("src") ?? "",
|
|
10162
|
+
poster: el.getAttribute("poster") ?? "",
|
|
10163
|
+
href: "",
|
|
10164
|
+
caption
|
|
10165
|
+
};
|
|
10166
|
+
}
|
|
10167
|
+
function figureOf(el, out) {
|
|
10168
|
+
const caption = words2(el.querySelector("figcaption"));
|
|
10169
|
+
const video = el.querySelector("video");
|
|
10170
|
+
if (video) {
|
|
10171
|
+
out.push(videoOf(video, caption));
|
|
10172
|
+
return;
|
|
10173
|
+
}
|
|
10174
|
+
const image2 = el.querySelector("img");
|
|
10175
|
+
const block2 = image2 ? imageOf(image2, caption) : void 0;
|
|
10176
|
+
if (block2) {
|
|
10177
|
+
out.push(block2);
|
|
10178
|
+
return;
|
|
10179
|
+
}
|
|
10180
|
+
const text2 = words2(el);
|
|
10181
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
10182
|
+
}
|
|
10183
|
+
function paragraphOf(el, out) {
|
|
10184
|
+
const solid = [...el.childNodes].filter(
|
|
10185
|
+
(n3) => n3.nodeType !== Node.TEXT_NODE || (n3.textContent ?? "").trim() !== ""
|
|
10186
|
+
);
|
|
10187
|
+
const only = solid.length === 1 ? solid[0] : void 0;
|
|
10188
|
+
if (only instanceof HTMLAnchorElement && PLAYER.test(only.getAttribute("href") ?? "")) {
|
|
10189
|
+
const href = only.getAttribute("href") ?? "";
|
|
10190
|
+
out.push({ kind: "video", src: "", poster: "", href, caption: words2(only) });
|
|
10191
|
+
return;
|
|
10192
|
+
}
|
|
10193
|
+
let buffer = "";
|
|
10194
|
+
const flush = () => {
|
|
10195
|
+
const text2 = buffer.replace(/\s+/g, " ").trim();
|
|
10196
|
+
if (text2) out.push({ kind: "paragraph", text: text2 });
|
|
10197
|
+
buffer = "";
|
|
10198
|
+
};
|
|
10199
|
+
const scan = (node) => {
|
|
10200
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
10201
|
+
buffer += node.textContent ?? "";
|
|
10202
|
+
return;
|
|
10203
|
+
}
|
|
10204
|
+
if (!(node instanceof Element)) return;
|
|
10205
|
+
if (skipped(node)) return;
|
|
10206
|
+
const tag = node.tagName.toLowerCase();
|
|
10207
|
+
if (tag === "br") {
|
|
10208
|
+
buffer += " ";
|
|
10209
|
+
return;
|
|
10210
|
+
}
|
|
10211
|
+
if (tag === "img" || tag === "video" || BLOCKY.has(tag)) {
|
|
10212
|
+
flush();
|
|
10213
|
+
block(node, out);
|
|
10214
|
+
return;
|
|
10215
|
+
}
|
|
10216
|
+
for (const kid of node.childNodes) scan(kid);
|
|
10217
|
+
};
|
|
10218
|
+
for (const kid of el.childNodes) scan(kid);
|
|
10219
|
+
flush();
|
|
10220
|
+
}
|
|
10221
|
+
function listOf(el) {
|
|
10222
|
+
return {
|
|
10223
|
+
kind: "list",
|
|
10224
|
+
ordered: el.tagName === "OL",
|
|
10225
|
+
// A nested list flattens into its parent item, exactly as `blockText` in
|
|
10226
|
+
// src/source/markdown.ts already flattens one on the way back out.
|
|
10227
|
+
items: [...el.children].filter((li) => li.tagName === "LI").map(words2).filter((text2) => text2 !== "")
|
|
10228
|
+
};
|
|
10229
|
+
}
|
|
10230
|
+
function tableOf(el) {
|
|
10231
|
+
const rows = [...el.querySelectorAll("tr")].map(
|
|
10232
|
+
(tr) => [...tr.children].filter((c) => c.tagName === "TD" || c.tagName === "TH").map(words2)
|
|
10233
|
+
);
|
|
10234
|
+
const [head, ...body] = rows;
|
|
10235
|
+
return head ? { kind: "table", columns: head, rows: body } : void 0;
|
|
10236
|
+
}
|
|
10237
|
+
function block(el, out) {
|
|
10238
|
+
if (skipped(el)) return;
|
|
10239
|
+
const tag = el.tagName.toLowerCase();
|
|
10240
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
10241
|
+
const text2 = words2(el);
|
|
10242
|
+
if (text2) out.push({ kind: "heading", depth: Number(tag[1]), text: text2 });
|
|
10243
|
+
return;
|
|
10244
|
+
}
|
|
10245
|
+
if (tag === "p" || tag === "blockquote") {
|
|
10246
|
+
paragraphOf(el, out);
|
|
10247
|
+
return;
|
|
10248
|
+
}
|
|
10249
|
+
if (tag === "ul" || tag === "ol") {
|
|
10250
|
+
const list = listOf(el);
|
|
10251
|
+
if (list.kind === "list" && list.items.length > 0) out.push(list);
|
|
10252
|
+
return;
|
|
10253
|
+
}
|
|
10254
|
+
if (tag === "table") {
|
|
10255
|
+
const table = tableOf(el);
|
|
10256
|
+
if (table) out.push(table);
|
|
10257
|
+
return;
|
|
10258
|
+
}
|
|
10259
|
+
if (tag === "figure") {
|
|
10260
|
+
figureOf(el, out);
|
|
10261
|
+
return;
|
|
10262
|
+
}
|
|
10263
|
+
if (tag === "img") {
|
|
10264
|
+
const image2 = imageOf(el, "");
|
|
10265
|
+
if (image2) out.push(image2);
|
|
10266
|
+
return;
|
|
10267
|
+
}
|
|
10268
|
+
if (tag === "video") {
|
|
10269
|
+
out.push(videoOf(el, ""));
|
|
10270
|
+
return;
|
|
10271
|
+
}
|
|
10272
|
+
if (tag === "iframe") {
|
|
10273
|
+
const href = el.getAttribute("src") ?? "";
|
|
10274
|
+
if (PLAYER.test(href)) {
|
|
10275
|
+
out.push({
|
|
10276
|
+
kind: "video",
|
|
10277
|
+
src: "",
|
|
10278
|
+
poster: "",
|
|
10279
|
+
href,
|
|
10280
|
+
caption: words2(el.getAttributeNode("title"))
|
|
10281
|
+
});
|
|
10282
|
+
}
|
|
10283
|
+
return;
|
|
10284
|
+
}
|
|
10285
|
+
if (tag === "pre") {
|
|
10286
|
+
const text2 = (el.textContent ?? "").replace(/\s+$/, "");
|
|
10287
|
+
if (text2) out.push({ kind: "code", text: text2 });
|
|
10288
|
+
return;
|
|
10289
|
+
}
|
|
10290
|
+
const direct = [...el.childNodes].some(
|
|
10291
|
+
(n3) => n3.nodeType === Node.TEXT_NODE && (n3.textContent ?? "").trim() !== ""
|
|
10292
|
+
);
|
|
10293
|
+
if (direct) {
|
|
10294
|
+
paragraphOf(el, out);
|
|
10295
|
+
return;
|
|
10296
|
+
}
|
|
10297
|
+
walk(el, out);
|
|
10298
|
+
}
|
|
10299
|
+
function walk(el, out) {
|
|
10300
|
+
for (const kid of el.children) block(kid, out);
|
|
10301
|
+
}
|
|
10302
|
+
function score(el) {
|
|
10303
|
+
let text2 = 0;
|
|
10304
|
+
for (const p of el.querySelectorAll("p, li, td, h1, h2, h3, h4, h5, h6")) {
|
|
10305
|
+
text2 += (p.textContent ?? "").trim().length;
|
|
10306
|
+
}
|
|
10307
|
+
let links = 0;
|
|
10308
|
+
for (const a of el.querySelectorAll("a")) links += (a.textContent ?? "").trim().length;
|
|
10309
|
+
return text2 - links;
|
|
10310
|
+
}
|
|
10311
|
+
function pickRoot() {
|
|
10312
|
+
const marked = document.querySelector(`[${marker}]`);
|
|
10313
|
+
if (marked) return marked;
|
|
10314
|
+
const main = document.querySelector("main");
|
|
10315
|
+
if (main && score(main) > 0) return main;
|
|
10316
|
+
const article = document.querySelector("article");
|
|
10317
|
+
if (article && score(article) > 0) return article;
|
|
10318
|
+
let best = document.body;
|
|
10319
|
+
let top = score(document.body);
|
|
10320
|
+
for (const el of document.body.querySelectorAll("div, section, td")) {
|
|
10321
|
+
const here = score(el);
|
|
10322
|
+
if (here >= top) {
|
|
10323
|
+
top = here;
|
|
10324
|
+
best = el;
|
|
10325
|
+
}
|
|
10326
|
+
}
|
|
10327
|
+
return best;
|
|
10328
|
+
}
|
|
10329
|
+
const root = pickRoot();
|
|
10330
|
+
const blocks = [];
|
|
10331
|
+
walk(root, blocks);
|
|
10332
|
+
const heading = words2(root.querySelector("h1")) || words2(document.querySelector("h1"));
|
|
10333
|
+
const og = document.querySelector('meta[property="og:image"], meta[name="og:image"]') ?? document.querySelector('meta[property="og:image:url"], meta[name="twitter:image"]');
|
|
10334
|
+
return {
|
|
10335
|
+
title: heading || (document.title ?? "").replace(/\s+/g, " ").trim(),
|
|
10336
|
+
base: document.querySelector("base[href]")?.getAttribute("href") ?? "",
|
|
10337
|
+
ogImage: og?.getAttribute("content") ?? "",
|
|
10338
|
+
blocks
|
|
10339
|
+
};
|
|
10340
|
+
}
|
|
10341
|
+
async function localise(seen, base, dir, opts, timeoutMs, warnings) {
|
|
10342
|
+
const maxAssets = opts.maxAssets ?? MAX_ASSETS;
|
|
10343
|
+
const maxClips = opts.maxClips ?? MAX_CLIPS;
|
|
10344
|
+
const maxBytes = opts.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
10345
|
+
const wallMs = opts.maxWallMs ?? MAX_WALL_MS;
|
|
10346
|
+
const deadline = Date.now() + wallMs;
|
|
10347
|
+
const out = [];
|
|
10348
|
+
const assets = [];
|
|
10349
|
+
const clips = [];
|
|
10350
|
+
let spent = 0;
|
|
10351
|
+
const done = /* @__PURE__ */ new Map();
|
|
10352
|
+
const ref = (got) => opts.refs === "relative" ? basename3(got.path) : got.path;
|
|
10353
|
+
const overdrawn = () => {
|
|
10354
|
+
if (Date.now() >= deadline) {
|
|
10355
|
+
return `the harvest has used its ${Math.round(wallMs / 1e3)}s budget \u2014 raise maxWallMs`;
|
|
10356
|
+
}
|
|
10357
|
+
if (spent >= maxBytes) {
|
|
10358
|
+
return `the harvest has downloaded ${mb(spent)} of its ${mb(maxBytes)} \u2014 raise maxTotalBytes`;
|
|
10359
|
+
}
|
|
10360
|
+
return null;
|
|
10361
|
+
};
|
|
10362
|
+
const bytesOf = async (url2) => {
|
|
10363
|
+
const got = await fetchGuarded(url2, {
|
|
10364
|
+
maxBytes: opts.maxAssetBytes ?? ASSET_MAX_BYTES,
|
|
10365
|
+
timeoutMs,
|
|
10366
|
+
...opts.allowLoopback === true ? { allowLoopback: true } : {}
|
|
10367
|
+
});
|
|
10368
|
+
spent += got.bytes.length;
|
|
10369
|
+
return got.bytes;
|
|
10370
|
+
};
|
|
10371
|
+
const grab = async (raw2, what) => {
|
|
10372
|
+
const url2 = absolute(raw2, base);
|
|
10373
|
+
if (url2 === null) {
|
|
10374
|
+
warnings.push(
|
|
10375
|
+
`${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.`
|
|
10376
|
+
);
|
|
10377
|
+
return null;
|
|
10378
|
+
}
|
|
10379
|
+
const already = done.get(url2);
|
|
10380
|
+
if (already !== void 0) return already;
|
|
10381
|
+
if (assets.length >= maxAssets) {
|
|
10382
|
+
done.set(url2, null);
|
|
10383
|
+
warnings.push(
|
|
10384
|
+
`${what} was left out: ${url2} \u2014 already at ${maxAssets} assets. Raise maxAssets if the page really has that many figures.`
|
|
10385
|
+
);
|
|
10386
|
+
return null;
|
|
8715
10387
|
}
|
|
8716
|
-
const
|
|
8717
|
-
if (
|
|
8718
|
-
|
|
10388
|
+
const capped = overdrawn();
|
|
10389
|
+
if (capped !== null) {
|
|
10390
|
+
done.set(url2, null);
|
|
10391
|
+
warnings.push(`${what} was left out: ${url2} \u2014 ${capped} if the page is worth the wait.`);
|
|
10392
|
+
return null;
|
|
10393
|
+
}
|
|
10394
|
+
let got = null;
|
|
10395
|
+
try {
|
|
10396
|
+
const bytes = await bytesOf(url2);
|
|
10397
|
+
const size3 = imageSize(bytes);
|
|
10398
|
+
if (size3.width < MIN_FIGURE_PX || size3.height < MIN_FIGURE_PX) {
|
|
10399
|
+
throw new Error(
|
|
10400
|
+
`it is ${size3.width}x${size3.height}, under ${MIN_FIGURE_PX}px \u2014 spacers, icons and tracking pixels look like this, and a slide cannot use one`
|
|
10401
|
+
);
|
|
10402
|
+
}
|
|
10403
|
+
const path2 = join11(dir, assetName(url2, extFor2(sniffFormat(bytes))));
|
|
10404
|
+
await writeFile11(path2, bytes);
|
|
10405
|
+
assets.push(path2);
|
|
10406
|
+
got = { path: path2, width: size3.width, height: size3.height };
|
|
10407
|
+
} catch (err) {
|
|
10408
|
+
got = null;
|
|
10409
|
+
warnings.push(`${what} was left out: ${url2} \u2014 ${why2(err)}`);
|
|
10410
|
+
}
|
|
10411
|
+
done.set(url2, got);
|
|
10412
|
+
return got;
|
|
10413
|
+
};
|
|
10414
|
+
const shrink = async (url2, path2, measured, what) => {
|
|
10415
|
+
if (opts.transcode === false) return { path: path2, ...measured };
|
|
10416
|
+
const out2 = join11(dir, assetName(url2, ".vp9.webm"));
|
|
10417
|
+
let small;
|
|
10418
|
+
try {
|
|
10419
|
+
small = await transcode(path2, out2, {
|
|
10420
|
+
...opts.maxClipSeconds === void 0 ? {} : { maxSeconds: opts.maxClipSeconds }
|
|
10421
|
+
});
|
|
10422
|
+
} catch (err) {
|
|
10423
|
+
warnings.push(`${what} was shipped as the page served it: ${why2(err)}`);
|
|
10424
|
+
return { path: path2, ...measured };
|
|
10425
|
+
}
|
|
10426
|
+
for (const w of small.warnings) warnings.push(`${what} \u2014 ${w}`);
|
|
10427
|
+
if (small.transcoded) {
|
|
10428
|
+
const [before, after] = await Promise.all([sizeOf2(path2), sizeOf2(small.path)]);
|
|
10429
|
+
if (before > 0 && after > before) {
|
|
10430
|
+
warnings.push(
|
|
10431
|
+
`${what} \u2014 ${basename3(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.`
|
|
10432
|
+
);
|
|
10433
|
+
}
|
|
10434
|
+
await rm7(path2, { force: true });
|
|
8719
10435
|
}
|
|
8720
10436
|
return {
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
segments: plan.audio.length,
|
|
8726
|
-
burned: burn,
|
|
8727
|
-
playback,
|
|
8728
|
-
// The cues as written, not `plan.cues` — at `playback > 1` these are the
|
|
8729
|
-
// scaled ones, so the number the caller prints is the rate on the file it
|
|
8730
|
-
// just got rather than the rate on a timeline that no longer exists.
|
|
8731
|
-
captionCps: p95CueRate(cues)
|
|
10437
|
+
path: small.path,
|
|
10438
|
+
width: small.width,
|
|
10439
|
+
height: small.height,
|
|
10440
|
+
...small.seconds === void 0 ? {} : { seconds: small.seconds }
|
|
8732
10441
|
};
|
|
8733
|
-
}
|
|
8734
|
-
|
|
10442
|
+
};
|
|
10443
|
+
const grabVideo = async (url2, what) => {
|
|
10444
|
+
if (clips.length >= maxClips) {
|
|
10445
|
+
warnings.push(
|
|
10446
|
+
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.`
|
|
10447
|
+
);
|
|
10448
|
+
return null;
|
|
10449
|
+
}
|
|
10450
|
+
const capped = overdrawn();
|
|
10451
|
+
if (capped !== null) {
|
|
10452
|
+
warnings.push(`${what} is a link only: ${capped} if the video is worth the wait.`);
|
|
10453
|
+
return null;
|
|
10454
|
+
}
|
|
10455
|
+
try {
|
|
10456
|
+
const bytes = await bytesOf(url2);
|
|
10457
|
+
const measured = videoSize(bytes);
|
|
10458
|
+
const path2 = join11(dir, assetName(url2, `.${measured.container}`));
|
|
10459
|
+
await writeFile11(path2, bytes);
|
|
10460
|
+
return await shrink(url2, path2, measured, what);
|
|
10461
|
+
} catch (err) {
|
|
10462
|
+
warnings.push(`${what} was not downloaded: ${url2} \u2014 ${why2(err)}`);
|
|
10463
|
+
return null;
|
|
10464
|
+
}
|
|
10465
|
+
};
|
|
10466
|
+
let ogSpent = false;
|
|
10467
|
+
const stillFor = async (poster, named, needed) => {
|
|
10468
|
+
if (poster) return grab(poster, `the poster of ${named}`);
|
|
10469
|
+
if (needed && seen.ogImage && !ogSpent) {
|
|
10470
|
+
ogSpent = true;
|
|
10471
|
+
return grab(seen.ogImage, `the page's og:image, taken as the still of ${named}`);
|
|
10472
|
+
}
|
|
10473
|
+
return null;
|
|
10474
|
+
};
|
|
10475
|
+
for (const item of seen.blocks) {
|
|
10476
|
+
if (item.kind === "image") {
|
|
10477
|
+
const named = item.caption || item.alt;
|
|
10478
|
+
const got = await grab(item.src, named ? `the image "${named}"` : "an image");
|
|
10479
|
+
if (got) out.push({ ...item, src: ref(got) });
|
|
10480
|
+
continue;
|
|
10481
|
+
}
|
|
10482
|
+
if (item.kind === "video") {
|
|
10483
|
+
const named = item.caption ? `the video "${item.caption}"` : "a video";
|
|
10484
|
+
const file = absolute(item.src, base);
|
|
10485
|
+
const page = absolute(item.href, base);
|
|
10486
|
+
const subject = file ?? page;
|
|
10487
|
+
const watch = page ?? file;
|
|
10488
|
+
const policy = subject === null ? null : policyFor(subject, "bake");
|
|
10489
|
+
const held = policy === "bake" && subject !== null ? await grabVideo(subject, named) : null;
|
|
10490
|
+
const still = await stillFor(item.poster, named, held === null);
|
|
10491
|
+
if (held !== null) {
|
|
10492
|
+
clips.push({
|
|
10493
|
+
file: held.path,
|
|
10494
|
+
poster: still?.path ?? "",
|
|
10495
|
+
href: "",
|
|
10496
|
+
width: held.width,
|
|
10497
|
+
height: held.height,
|
|
10498
|
+
...held.seconds === void 0 ? {} : { seconds: held.seconds },
|
|
10499
|
+
caption: item.caption
|
|
10500
|
+
});
|
|
10501
|
+
out.push({ ...item, src: "", poster: still ? ref(still) : "", href: "" });
|
|
10502
|
+
continue;
|
|
10503
|
+
}
|
|
10504
|
+
if (still !== null && watch !== null) {
|
|
10505
|
+
clips.push({
|
|
10506
|
+
file: "",
|
|
10507
|
+
poster: still.path,
|
|
10508
|
+
href: watch,
|
|
10509
|
+
width: still.width,
|
|
10510
|
+
height: still.height,
|
|
10511
|
+
caption: item.caption
|
|
10512
|
+
});
|
|
10513
|
+
warnings.push(
|
|
10514
|
+
`${named} is a link only: ${unheld(policy)}. The still is what the deck shows, and a viewer goes to ${watch}.`
|
|
10515
|
+
);
|
|
10516
|
+
out.push({ ...item, src: "", poster: ref(still), href: watch });
|
|
10517
|
+
continue;
|
|
10518
|
+
}
|
|
10519
|
+
if (still !== null) {
|
|
10520
|
+
warnings.push(
|
|
10521
|
+
`${named} is a still only: the page names no source for it, so there is nothing to play.`
|
|
10522
|
+
);
|
|
10523
|
+
out.push({ ...item, src: "", poster: ref(still), href: "" });
|
|
10524
|
+
continue;
|
|
10525
|
+
}
|
|
10526
|
+
if (watch === null) {
|
|
10527
|
+
warnings.push(`${named} was left out: it names neither a poster image nor a URL.`);
|
|
10528
|
+
continue;
|
|
10529
|
+
}
|
|
10530
|
+
warnings.push(
|
|
10531
|
+
`${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.`
|
|
10532
|
+
);
|
|
10533
|
+
out.push({ ...item, src: "", poster: "", href: watch });
|
|
10534
|
+
continue;
|
|
10535
|
+
}
|
|
10536
|
+
out.push(item);
|
|
8735
10537
|
}
|
|
10538
|
+
return { blocks: out, assets, clips };
|
|
8736
10539
|
}
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
});
|
|
8744
|
-
const timing = JSON.parse(text2);
|
|
8745
|
-
if (timing.version !== 1) throw new Error(`${path2} is version ${timing.version}; expected 1.`);
|
|
8746
|
-
return timing;
|
|
10540
|
+
function unheld(policy) {
|
|
10541
|
+
if (policy === "embed") return "it is a player page, which is never downloaded";
|
|
10542
|
+
if (policy === "link") {
|
|
10543
|
+
return "its URL does not end in a video extension, so what came back could as easily be a page";
|
|
10544
|
+
}
|
|
10545
|
+
return "the file could not be held";
|
|
8747
10546
|
}
|
|
8748
|
-
function
|
|
8749
|
-
|
|
10547
|
+
async function sizeOf2(path2) {
|
|
10548
|
+
return await stat(path2).then(
|
|
10549
|
+
(s) => s.size,
|
|
10550
|
+
() => 0
|
|
10551
|
+
);
|
|
10552
|
+
}
|
|
10553
|
+
function mb(bytes) {
|
|
10554
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
10555
|
+
}
|
|
10556
|
+
function absolute(raw2, base) {
|
|
10557
|
+
const trimmed = raw2.trim();
|
|
10558
|
+
if (!trimmed) return null;
|
|
10559
|
+
try {
|
|
10560
|
+
const url2 = new URL(trimmed, base);
|
|
10561
|
+
return url2.protocol === "http:" || url2.protocol === "https:" ? url2.href : null;
|
|
10562
|
+
} catch {
|
|
10563
|
+
return null;
|
|
10564
|
+
}
|
|
10565
|
+
}
|
|
10566
|
+
function assetName(url2, ext) {
|
|
10567
|
+
const last = new URL(url2).pathname.split("/").pop() ?? "";
|
|
10568
|
+
const stem = last.replace(/\.[^.]*$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "figure";
|
|
10569
|
+
const hash = createHash7("sha256").update(url2).digest("hex").slice(0, 8);
|
|
10570
|
+
return `${stem}-${hash}${ext}`;
|
|
10571
|
+
}
|
|
10572
|
+
function extFor2(format) {
|
|
10573
|
+
if (format === void 0) return ".img";
|
|
10574
|
+
return format === "jpeg" ? ".jpg" : `.${format}`;
|
|
10575
|
+
}
|
|
10576
|
+
function why2(err) {
|
|
10577
|
+
return err instanceof Error ? err.message : String(err);
|
|
10578
|
+
}
|
|
10579
|
+
function sniffVideo(b) {
|
|
10580
|
+
if (b.length >= 12 && b.toString("latin1", 4, 8) === "ftyp") return "mp4";
|
|
10581
|
+
if (b.length >= 4 && b.readUInt32BE(0) === 440786851) return "webm";
|
|
10582
|
+
return void 0;
|
|
10583
|
+
}
|
|
10584
|
+
function videoSize(b) {
|
|
10585
|
+
const container = sniffVideo(b);
|
|
10586
|
+
if (container === void 0) {
|
|
8750
10587
|
throw new Error(
|
|
8751
|
-
|
|
10588
|
+
"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"
|
|
8752
10589
|
);
|
|
8753
10590
|
}
|
|
8754
|
-
|
|
10591
|
+
const measured = container === "mp4" ? mp4Size(b) : webmSize(b);
|
|
10592
|
+
if (measured.width < MIN_FIGURE_PX || measured.height < MIN_FIGURE_PX) {
|
|
8755
10593
|
throw new Error(
|
|
8756
|
-
`
|
|
10594
|
+
`it measures ${measured.width}x${measured.height}, under ${MIN_FIGURE_PX}px \u2014 a slide cannot use one`
|
|
8757
10595
|
);
|
|
8758
10596
|
}
|
|
10597
|
+
return { ...measured, container };
|
|
8759
10598
|
}
|
|
8760
|
-
function
|
|
8761
|
-
const
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
10599
|
+
function boxes2(b, from, to) {
|
|
10600
|
+
const out = [];
|
|
10601
|
+
let at = from;
|
|
10602
|
+
while (at + 8 <= to) {
|
|
10603
|
+
let size3 = b.readUInt32BE(at);
|
|
10604
|
+
const type = b.toString("latin1", at + 4, at + 8);
|
|
10605
|
+
let body = at + 8;
|
|
10606
|
+
if (size3 === 1) {
|
|
10607
|
+
if (body + 8 > to) return out;
|
|
10608
|
+
size3 = Number(b.readBigUInt64BE(body));
|
|
10609
|
+
body += 8;
|
|
10610
|
+
} else if (size3 === 0) {
|
|
10611
|
+
size3 = to - at;
|
|
10612
|
+
}
|
|
10613
|
+
const end = at + size3;
|
|
10614
|
+
if (size3 < body - at || end > to) return out;
|
|
10615
|
+
out.push({ type, body, end });
|
|
10616
|
+
at = end;
|
|
10617
|
+
}
|
|
8772
10618
|
return out;
|
|
8773
10619
|
}
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
10620
|
+
function mp4Size(b) {
|
|
10621
|
+
const moov = boxes2(b, 0, b.length).find((box) => box.type === "moov");
|
|
10622
|
+
if (moov === void 0) {
|
|
10623
|
+
throw new Error(
|
|
10624
|
+
"it has no moov box \u2014 the file is truncated, or it is a fragmented stream whose header never arrived"
|
|
10625
|
+
);
|
|
10626
|
+
}
|
|
10627
|
+
let seconds;
|
|
10628
|
+
let size3;
|
|
10629
|
+
for (const box of boxes2(b, moov.body, moov.end)) {
|
|
10630
|
+
if (box.type === "mvhd") seconds = mvhdSeconds(b, box.body);
|
|
10631
|
+
if (box.type !== "trak") continue;
|
|
10632
|
+
for (const inner of boxes2(b, box.body, box.end)) {
|
|
10633
|
+
if (inner.type === "tkhd" && size3 === void 0) size3 = tkhdSize(b, inner.body);
|
|
8783
10634
|
}
|
|
8784
10635
|
}
|
|
8785
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
`
|
|
8790
|
-
);
|
|
8791
|
-
const joined = join10(work, "retimed.mp4");
|
|
8792
|
-
await runTool("ffmpeg", [
|
|
8793
|
-
"-y",
|
|
8794
|
-
"-hide_banner",
|
|
8795
|
-
"-loglevel",
|
|
8796
|
-
"error",
|
|
8797
|
-
"-f",
|
|
8798
|
-
"concat",
|
|
8799
|
-
"-safe",
|
|
8800
|
-
"0",
|
|
8801
|
-
"-i",
|
|
8802
|
-
listFile,
|
|
8803
|
-
"-c",
|
|
8804
|
-
"copy",
|
|
8805
|
-
"-movflags",
|
|
8806
|
-
"+faststart",
|
|
8807
|
-
joined
|
|
8808
|
-
]);
|
|
8809
|
-
return joined;
|
|
8810
|
-
}
|
|
8811
|
-
async function mux(video, timing, plan, deck, out, work, burnCues, fps, log) {
|
|
8812
|
-
const inputs = plan.audio.map((a) => ({
|
|
8813
|
-
file: join10(deck, timing.audioDir, a.audio),
|
|
8814
|
-
delayMs: a.delayMs
|
|
8815
|
-
}));
|
|
8816
|
-
const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", video];
|
|
8817
|
-
const graph = [];
|
|
8818
|
-
const band = burnCues ? await renderCaptions(
|
|
8819
|
-
burnCues,
|
|
8820
|
-
burnStyle(timing.width, timing.height, familyFor(timing.lang) ?? "Arial"),
|
|
8821
|
-
deck,
|
|
8822
|
-
work
|
|
8823
|
-
) : void 0;
|
|
8824
|
-
if (band) {
|
|
8825
|
-
log(`render: drew ${band.files.length} caption band(s), ${band.width}\xD7${band.height}px`);
|
|
8826
|
-
graph.push(overlayGraph(burnCues, band));
|
|
10636
|
+
if (size3 === void 0) {
|
|
10637
|
+
throw new Error(
|
|
10638
|
+
"no track in it declares a width and a height, so there is no picture to place"
|
|
10639
|
+
);
|
|
8827
10640
|
}
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
10641
|
+
return { ...size3, ...seconds === void 0 ? {} : { seconds } };
|
|
10642
|
+
}
|
|
10643
|
+
function tkhdSize(b, body) {
|
|
10644
|
+
const matrix = body + (b[body] === 1 ? 52 : 40);
|
|
10645
|
+
const at = matrix + 36;
|
|
10646
|
+
if (at + 8 > b.length) return void 0;
|
|
10647
|
+
const width = Math.round(b.readUInt32BE(at) / 65536);
|
|
10648
|
+
const height = Math.round(b.readUInt32BE(at + 4) / 65536);
|
|
10649
|
+
if (width <= 0 || height <= 0) return void 0;
|
|
10650
|
+
const quarterTurn = b.readInt32BE(matrix) === 0 && b.readInt32BE(matrix + 16) === 0;
|
|
10651
|
+
return quarterTurn ? { width: height, height: width } : { width, height };
|
|
10652
|
+
}
|
|
10653
|
+
function mvhdSeconds(b, body) {
|
|
10654
|
+
const long = b[body] === 1;
|
|
10655
|
+
const at = body + (long ? 20 : 12);
|
|
10656
|
+
if (at + (long ? 12 : 8) > b.length) return void 0;
|
|
10657
|
+
const timescale = b.readUInt32BE(at);
|
|
10658
|
+
const ticks = long ? Number(b.readBigUInt64BE(at + 4)) : b.readUInt32BE(at + 4);
|
|
10659
|
+
if (timescale <= 0 || ticks <= 0 || ticks === 4294967295) return void 0;
|
|
10660
|
+
return sane(ticks / timescale);
|
|
10661
|
+
}
|
|
10662
|
+
var EBML_SEGMENT = 408125543;
|
|
10663
|
+
var EBML_INFO = 357149030;
|
|
10664
|
+
var EBML_TRACKS = 374648427;
|
|
10665
|
+
var EBML_TRACK_ENTRY = 174;
|
|
10666
|
+
var EBML_VIDEO = 224;
|
|
10667
|
+
var EBML_PIXEL_WIDTH = 176;
|
|
10668
|
+
var EBML_PIXEL_HEIGHT = 186;
|
|
10669
|
+
var EBML_DISPLAY_WIDTH = 21680;
|
|
10670
|
+
var EBML_DISPLAY_HEIGHT = 21690;
|
|
10671
|
+
var EBML_TIMECODE_SCALE = 2807729;
|
|
10672
|
+
var EBML_DURATION = 17545;
|
|
10673
|
+
function webmSize(b) {
|
|
10674
|
+
let pixel = { width: 0, height: 0 };
|
|
10675
|
+
let display = { width: 0, height: 0 };
|
|
10676
|
+
let scale = 1e6;
|
|
10677
|
+
let ticks;
|
|
10678
|
+
const masters = /* @__PURE__ */ new Set([EBML_SEGMENT, EBML_INFO, EBML_TRACKS, EBML_TRACK_ENTRY, EBML_VIDEO]);
|
|
10679
|
+
const scan = (from, to, depth) => {
|
|
10680
|
+
let at = from;
|
|
10681
|
+
while (at < to) {
|
|
10682
|
+
const el = ebml(b, at, to);
|
|
10683
|
+
if (el === void 0 || el.end <= at) return;
|
|
10684
|
+
if (masters.has(el.id)) {
|
|
10685
|
+
if (depth < 6) scan(el.body, el.end, depth + 1);
|
|
10686
|
+
} else if (el.id === EBML_PIXEL_WIDTH && pixel.width === 0) {
|
|
10687
|
+
pixel = { ...pixel, width: uint(b, el) };
|
|
10688
|
+
} else if (el.id === EBML_PIXEL_HEIGHT && pixel.height === 0) {
|
|
10689
|
+
pixel = { ...pixel, height: uint(b, el) };
|
|
10690
|
+
} else if (el.id === EBML_DISPLAY_WIDTH && display.width === 0) {
|
|
10691
|
+
display = { ...display, width: uint(b, el) };
|
|
10692
|
+
} else if (el.id === EBML_DISPLAY_HEIGHT && display.height === 0) {
|
|
10693
|
+
display = { ...display, height: uint(b, el) };
|
|
10694
|
+
} else if (el.id === EBML_TIMECODE_SCALE) {
|
|
10695
|
+
scale = uint(b, el) || scale;
|
|
10696
|
+
} else if (el.id === EBML_DURATION) {
|
|
10697
|
+
ticks = float(b, el);
|
|
10698
|
+
}
|
|
10699
|
+
at = el.end;
|
|
10700
|
+
}
|
|
10701
|
+
};
|
|
10702
|
+
scan(0, b.length, 0);
|
|
10703
|
+
const width = display.width || pixel.width;
|
|
10704
|
+
const height = display.height || pixel.height;
|
|
10705
|
+
if (width <= 0 || height <= 0) {
|
|
10706
|
+
throw new Error(
|
|
10707
|
+
"no track in it declares PixelWidth and PixelHeight, so there is no picture to place"
|
|
10708
|
+
);
|
|
8832
10709
|
}
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
10710
|
+
const seconds = ticks === void 0 ? void 0 : sane(ticks * scale / 1e9);
|
|
10711
|
+
return { width, height, ...seconds === void 0 ? {} : { seconds } };
|
|
10712
|
+
}
|
|
10713
|
+
function ebml(b, at, to) {
|
|
10714
|
+
const idLen = vintLen(b[at]);
|
|
10715
|
+
if (idLen === 0 || at + idLen > to) return void 0;
|
|
10716
|
+
let id2 = 0;
|
|
10717
|
+
for (let i = 0; i < idLen; i += 1) id2 = id2 * 256 + (b[at + i] ?? 0);
|
|
10718
|
+
let p = at + idLen;
|
|
10719
|
+
const sizeLen = vintLen(b[p]);
|
|
10720
|
+
if (sizeLen === 0 || p + sizeLen > to) return void 0;
|
|
10721
|
+
const first = b[p] ?? 0;
|
|
10722
|
+
const mask = 255 >> sizeLen;
|
|
10723
|
+
let size3 = first & mask;
|
|
10724
|
+
let unknown = size3 === mask;
|
|
10725
|
+
for (let i = 1; i < sizeLen; i += 1) {
|
|
10726
|
+
const byte = b[p + i] ?? 0;
|
|
10727
|
+
size3 = size3 * 256 + byte;
|
|
10728
|
+
unknown = unknown && byte === 255;
|
|
10729
|
+
}
|
|
10730
|
+
p += sizeLen;
|
|
10731
|
+
return { id: id2, body: p, end: unknown ? to : Math.min(p + size3, to) };
|
|
10732
|
+
}
|
|
10733
|
+
function vintLen(first) {
|
|
10734
|
+
if (first === void 0 || first === 0) return 0;
|
|
10735
|
+
let len = 1;
|
|
10736
|
+
for (let mask = 128; (first & mask) === 0; mask >>= 1) len += 1;
|
|
10737
|
+
return len;
|
|
10738
|
+
}
|
|
10739
|
+
function uint(b, el) {
|
|
10740
|
+
let value = 0;
|
|
10741
|
+
for (let at = el.body; at < el.end && at - el.body < 8; at += 1)
|
|
10742
|
+
value = value * 256 + (b[at] ?? 0);
|
|
10743
|
+
return value;
|
|
10744
|
+
}
|
|
10745
|
+
function float(b, el) {
|
|
10746
|
+
const width = el.end - el.body;
|
|
10747
|
+
if (width === 4) return b.readFloatBE(el.body);
|
|
10748
|
+
if (width === 8) return b.readDoubleBE(el.body);
|
|
10749
|
+
return void 0;
|
|
10750
|
+
}
|
|
10751
|
+
function sane(seconds) {
|
|
10752
|
+
return Number.isFinite(seconds) && seconds > 0 && seconds < 86400 ? seconds : void 0;
|
|
10753
|
+
}
|
|
10754
|
+
async function attachClips(source, clips, dir) {
|
|
10755
|
+
if (clips.length === 0) return source;
|
|
10756
|
+
const assets = resolve4(dir);
|
|
10757
|
+
await mkdir9(assets, { recursive: true });
|
|
10758
|
+
const figures = [...source.figures];
|
|
10759
|
+
for (const clip3 of clips) {
|
|
10760
|
+
const src = clip3.file ? await adopt(clip3.file, assets) : void 0;
|
|
10761
|
+
const poster = clip3.poster ? await adopt(clip3.poster, assets) : void 0;
|
|
10762
|
+
if (src === void 0 && poster === void 0) continue;
|
|
10763
|
+
const at = clip3.poster === "" ? -1 : figures.findIndex((f) => f.src === clip3.poster || f.src === basename3(clip3.poster));
|
|
10764
|
+
const kept = at === -1 ? void 0 : figures[at];
|
|
10765
|
+
const figure = figureSchema.parse({
|
|
10766
|
+
id: kept?.id ?? freeId(figures),
|
|
10767
|
+
kind: "clip",
|
|
10768
|
+
// The file when we hold it, and the still when we do not: `claim-figure`
|
|
10769
|
+
// draws `poster` for a clip that carries an `href` and plays `src` for one
|
|
10770
|
+
// that does not, and `src` is required either way.
|
|
10771
|
+
src: src ?? poster,
|
|
10772
|
+
caption: kept?.caption || clip3.caption,
|
|
10773
|
+
width: clip3.width,
|
|
10774
|
+
height: clip3.height,
|
|
10775
|
+
...kept?.sectionId === void 0 ? {} : { sectionId: kept.sectionId },
|
|
10776
|
+
...kept?.mention === void 0 ? {} : { mention: kept.mention },
|
|
10777
|
+
...poster === void 0 ? {} : { poster },
|
|
10778
|
+
...clip3.seconds === void 0 ? {} : { seconds: clip3.seconds },
|
|
10779
|
+
...clip3.href === "" ? {} : { href: clip3.href }
|
|
10780
|
+
});
|
|
10781
|
+
if (at === -1) figures.push(figure);
|
|
10782
|
+
else figures[at] = figure;
|
|
8838
10783
|
}
|
|
8839
|
-
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
|
|
8843
|
-
|
|
8844
|
-
|
|
10784
|
+
return { ...source, figures };
|
|
10785
|
+
}
|
|
10786
|
+
async function adopt(file, dir) {
|
|
10787
|
+
const name = basename3(file);
|
|
10788
|
+
await copyFile(file, join11(dir, name));
|
|
10789
|
+
return name;
|
|
10790
|
+
}
|
|
10791
|
+
function freeId(figures) {
|
|
10792
|
+
const taken = new Set(figures.map((f) => f.id));
|
|
10793
|
+
let n3 = figures.length + 1;
|
|
10794
|
+
while (taken.has(`fig${n3}`)) n3 += 1;
|
|
10795
|
+
return `fig${n3}`;
|
|
10796
|
+
}
|
|
10797
|
+
function toMarkdown(blocks) {
|
|
10798
|
+
const out = [];
|
|
10799
|
+
for (const item of blocks) {
|
|
10800
|
+
switch (item.kind) {
|
|
10801
|
+
case "heading":
|
|
10802
|
+
out.push(`${"#".repeat(Math.min(Math.max(item.depth, 1), 6))} ${inline(item.text)}`);
|
|
10803
|
+
break;
|
|
10804
|
+
case "paragraph":
|
|
10805
|
+
out.push(inline(item.text));
|
|
10806
|
+
break;
|
|
10807
|
+
case "code":
|
|
10808
|
+
out.push(fenced(item.text));
|
|
10809
|
+
break;
|
|
10810
|
+
case "list":
|
|
10811
|
+
out.push(
|
|
10812
|
+
item.items.map((li, n3) => `${item.ordered ? `${n3 + 1}.` : "-"} ${inline(li)}`).join("\n")
|
|
10813
|
+
);
|
|
10814
|
+
break;
|
|
10815
|
+
case "table":
|
|
10816
|
+
out.push(pipes(item.columns, item.rows));
|
|
10817
|
+
break;
|
|
10818
|
+
case "image":
|
|
10819
|
+
out.push(`})`);
|
|
10820
|
+
if (item.caption || item.alt) out.push(`*${inline(item.caption || item.alt)}*`);
|
|
10821
|
+
break;
|
|
10822
|
+
case "video": {
|
|
10823
|
+
if (item.poster) {
|
|
10824
|
+
out.push(`})`);
|
|
10825
|
+
if (item.caption) out.push(`*${inline(item.caption)}*`);
|
|
10826
|
+
} else if (item.caption) {
|
|
10827
|
+
out.push(inline(item.caption));
|
|
10828
|
+
}
|
|
10829
|
+
const link = item.href || item.src;
|
|
10830
|
+
if (/^https?:/i.test(link)) out.push(`Video: <${link}>`);
|
|
10831
|
+
break;
|
|
10832
|
+
}
|
|
10833
|
+
}
|
|
8845
10834
|
}
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
10835
|
+
return `${out.filter((block) => block !== "").join("\n\n")}
|
|
10836
|
+
`;
|
|
10837
|
+
}
|
|
10838
|
+
function inline(text2) {
|
|
10839
|
+
return text2.replace(/\s+/g, " ").trim().replace(/([\\`*_[\]<>])/g, "\\$1").replace(/^(#{1,6}\s|[-+]\s)/, "\\$&").replace(/^(\d{1,9})([.)]\s)/, "$1\\$2");
|
|
10840
|
+
}
|
|
10841
|
+
function destination(path2) {
|
|
10842
|
+
if (/[<>]/.test(path2)) {
|
|
10843
|
+
throw new Error(
|
|
10844
|
+
`cannot reference ${path2} from markdown: a path containing < or > has no spelling as a link destination. Harvest into a directory without them.`
|
|
10845
|
+
);
|
|
10846
|
+
}
|
|
10847
|
+
return /[\s()]/.test(path2) ? `<${path2}>` : path2;
|
|
10848
|
+
}
|
|
10849
|
+
function pipes(columns2, rows) {
|
|
10850
|
+
const width = Math.max(columns2.length, ...rows.map((r) => r.length), 1);
|
|
10851
|
+
const cells = (row) => `| ${Array.from({ length: width }, (_, i) => inline(row[i] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`;
|
|
10852
|
+
const rule = `| ${Array.from({ length: width }, () => "---").join(" | ")} |`;
|
|
10853
|
+
return [cells(columns2), rule, ...rows.map(cells)].join("\n");
|
|
10854
|
+
}
|
|
10855
|
+
function fenced(code) {
|
|
10856
|
+
const runs = [...code.matchAll(/`+/g)].map((m) => m[0].length + 1);
|
|
10857
|
+
const fence = "`".repeat(Math.max(3, ...runs));
|
|
10858
|
+
return `${fence}
|
|
10859
|
+
${code}
|
|
10860
|
+
${fence}`;
|
|
8852
10861
|
}
|
|
8853
10862
|
|
|
8854
10863
|
// src/source/markdown.ts
|
|
8855
|
-
import { createHash as
|
|
10864
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
8856
10865
|
import remarkGfm from "remark-gfm";
|
|
8857
10866
|
import remarkMath from "remark-math";
|
|
8858
10867
|
import remarkParse from "remark-parse";
|
|
@@ -8904,6 +10913,9 @@ function parseMarkdown(md, opts = {}) {
|
|
|
8904
10913
|
for (const img of images) {
|
|
8905
10914
|
const figure = {
|
|
8906
10915
|
id: `fig${figures.length + 1}`,
|
|
10916
|
+
// Markdown carries no video: an image node is an image. A clip only
|
|
10917
|
+
// ever enters through `harvest`, which sets this itself.
|
|
10918
|
+
kind: "image",
|
|
8907
10919
|
src: img.url,
|
|
8908
10920
|
caption: caption ?? img.alt ?? "",
|
|
8909
10921
|
// 1x1 until assets.ts reads the actual bytes. The schema has no "unknown",
|
|
@@ -8939,7 +10951,7 @@ function parseMarkdown(md, opts = {}) {
|
|
|
8939
10951
|
if (mention) p.figure.mention = mention;
|
|
8940
10952
|
}
|
|
8941
10953
|
return sourceSchema.parse({
|
|
8942
|
-
id: opts.id ??
|
|
10954
|
+
id: opts.id ?? createHash8("sha256").update(md).digest("hex").slice(0, 12),
|
|
8943
10955
|
title: title2 || "Untitled",
|
|
8944
10956
|
lang: opts.lang ?? sniffLang(md),
|
|
8945
10957
|
sections,
|
|
@@ -8948,10 +10960,14 @@ function parseMarkdown(md, opts = {}) {
|
|
|
8948
10960
|
tables
|
|
8949
10961
|
});
|
|
8950
10962
|
}
|
|
10963
|
+
var SCRIPT_SHARE = 0.02;
|
|
8951
10964
|
function sniffLang(md) {
|
|
8952
|
-
|
|
8953
|
-
if (
|
|
8954
|
-
|
|
10965
|
+
const total = md.replace(/\s+/g, "").length;
|
|
10966
|
+
if (total === 0) return "en";
|
|
10967
|
+
const share2 = (re) => (md.match(re)?.length ?? 0) / total;
|
|
10968
|
+
if (share2(/[가-힣]/g) >= SCRIPT_SHARE) return "ko";
|
|
10969
|
+
if (share2(/[-ヿ]/g) >= SCRIPT_SHARE) return "ja";
|
|
10970
|
+
if (share2(/[一-鿿]/g) >= SCRIPT_SHARE) return "zh";
|
|
8955
10971
|
return "en";
|
|
8956
10972
|
}
|
|
8957
10973
|
function onlyImages(kids) {
|
|
@@ -8972,7 +10988,11 @@ function mentionOf(caption, prose, at, opened) {
|
|
|
8972
10988
|
const found = before ?? prose.find((p) => p.at > at && name.test(p.text));
|
|
8973
10989
|
if (found) return found.text;
|
|
8974
10990
|
}
|
|
8975
|
-
|
|
10991
|
+
const near = prose.filter((p) => p.at > opened && p.at < at).at(-1)?.text;
|
|
10992
|
+
return near !== void 0 && isSentence(near) ? near : void 0;
|
|
10993
|
+
}
|
|
10994
|
+
function isSentence(text2) {
|
|
10995
|
+
return (text2.match(/[\p{L}\p{N}]+/gu) ?? []).length >= 3;
|
|
8976
10996
|
}
|
|
8977
10997
|
function figureName(caption) {
|
|
8978
10998
|
const match = /^\s*(fig(?:ure)?\.?|그림|図|图)\s*([0-9]+)/i.exec(caption);
|
|
@@ -9033,8 +11053,8 @@ function textOf(node) {
|
|
|
9033
11053
|
}
|
|
9034
11054
|
|
|
9035
11055
|
// src/verify/index.ts
|
|
9036
|
-
import { readdir as
|
|
9037
|
-
import { join as
|
|
11056
|
+
import { readdir as readdir3, readFile as readFile16 } from "node:fs/promises";
|
|
11057
|
+
import { join as join15, relative, sep } from "node:path";
|
|
9038
11058
|
|
|
9039
11059
|
// src/verify/budget.ts
|
|
9040
11060
|
function readCanvas(html) {
|
|
@@ -9105,12 +11125,12 @@ function remedy(html, over, maxSeconds, profiles, storyboard, emitted) {
|
|
|
9105
11125
|
const kept = emitted ?? storyboard.beats.filter((b) => b.weight >= (profile?.minWeight ?? 0));
|
|
9106
11126
|
if (windows.length !== kept.length) return generic;
|
|
9107
11127
|
const seconds = Object.fromEntries(kept.map((b, i) => [b.id, windows[i] ?? 0]));
|
|
9108
|
-
const
|
|
11128
|
+
const budget3 = {
|
|
9109
11129
|
minWeight: profile?.minWeight ?? 0,
|
|
9110
11130
|
maxSeconds,
|
|
9111
11131
|
...profile && { id: profile.id }
|
|
9112
11132
|
};
|
|
9113
|
-
const cut = selectBeats(storyboard,
|
|
11133
|
+
const cut = selectBeats(storyboard, budget3, seconds);
|
|
9114
11134
|
const casualties = cut.dropped.filter((d) => d.rule !== "below_min_weight");
|
|
9115
11135
|
const selection = cut.fits ? `A budgeted cut keeps ${cut.kept.length} of ${kept.length} beats in ${clock2(cut.seconds)}, dropping ${casualties.length}. ` + casualties.map((d) => `${d.beat.id} (${d.beat.archetype}): ${d.reason}`).join(" ") + (cut.dangling.length > 0 ? ` Check the wording first: ${cut.dangling.map((d) => d.reason).join(" ")}` : "") + " " : `No cut of these beats fits ${clock2(maxSeconds)} \u2014 the narration itself has to get shorter. `;
|
|
9116
11136
|
const byWeight = kept.map((beat, i) => ({ beat, seconds: windows[i] ?? 0 })).sort((a, b) => a.beat.weight - b.beat.weight);
|
|
@@ -9141,8 +11161,8 @@ function clock2(seconds) {
|
|
|
9141
11161
|
|
|
9142
11162
|
// src/verify/check.ts
|
|
9143
11163
|
import { execFile as execFile2 } from "node:child_process";
|
|
9144
|
-
import { readFile as
|
|
9145
|
-
import { join as
|
|
11164
|
+
import { readFile as readFile13 } from "node:fs/promises";
|
|
11165
|
+
import { join as join12 } from "node:path";
|
|
9146
11166
|
import { promisify as promisify2 } from "node:util";
|
|
9147
11167
|
var run2 = promisify2(execFile2);
|
|
9148
11168
|
var DEFAULT_TIMEOUT_MS3 = 24e4;
|
|
@@ -9172,7 +11192,7 @@ async function check2(dir, opts = {}) {
|
|
|
9172
11192
|
async function readComposition(dir) {
|
|
9173
11193
|
let html;
|
|
9174
11194
|
try {
|
|
9175
|
-
html = await
|
|
11195
|
+
html = await readFile13(join12(dir, "index.html"), "utf8");
|
|
9176
11196
|
} catch {
|
|
9177
11197
|
return { transit: [], duration: 0 };
|
|
9178
11198
|
}
|
|
@@ -9253,11 +11273,11 @@ function regrade({ f, time, selector }, transit = []) {
|
|
|
9253
11273
|
(w) => time > w.t0 && time < w.t1 && (named === void 0 || named === w.sid)
|
|
9254
11274
|
);
|
|
9255
11275
|
if (hit) {
|
|
9256
|
-
const
|
|
11276
|
+
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";
|
|
9257
11277
|
return {
|
|
9258
11278
|
...f,
|
|
9259
11279
|
severity: "info",
|
|
9260
|
-
message: `${f.message} \u2014 accepted: mid-camera-move, ${
|
|
11280
|
+
message: `${f.message} \u2014 accepted: mid-camera-move, ${why3}.`
|
|
9261
11281
|
};
|
|
9262
11282
|
}
|
|
9263
11283
|
return { ...f, severity: "error" };
|
|
@@ -9270,7 +11290,7 @@ function regrade({ f, time, selector }, transit = []) {
|
|
|
9270
11290
|
var OFF_CANVAS = /* @__PURE__ */ new Set(["canvas_overflow", "panel_out_of_canvas", "text_occluded"]);
|
|
9271
11291
|
function toFinding(gate2, f) {
|
|
9272
11292
|
const severity = typeof f.severity === "string" && SEVERITIES.has(f.severity) ? f.severity : "warning";
|
|
9273
|
-
const
|
|
11293
|
+
const message2 = str(f.message) ?? "(no message)";
|
|
9274
11294
|
const time = typeof f.time === "number" && f.time > 0 ? f.time : void 0;
|
|
9275
11295
|
const at = time !== void 0 ? `t=${time}s` : void 0;
|
|
9276
11296
|
const where2 = [str(f.selector) ?? str(f.containerSelector), at].filter(Boolean).join(" ");
|
|
@@ -9281,7 +11301,7 @@ function toFinding(gate2, f) {
|
|
|
9281
11301
|
rule: str(f.code) ?? "unknown",
|
|
9282
11302
|
// "Text extends outside the composition canvas." is unactionable without the
|
|
9283
11303
|
// element it happened to, and Finding has nowhere else to put it.
|
|
9284
|
-
message: where2 ? `${
|
|
11304
|
+
message: where2 ? `${message2} [${where2}]` : message2
|
|
9285
11305
|
},
|
|
9286
11306
|
time,
|
|
9287
11307
|
selector: str(f.selector) ?? str(f.containerSelector)
|
|
@@ -9295,8 +11315,8 @@ function staticGuardFindings(stderr) {
|
|
|
9295
11315
|
message: line2.slice("[StaticGuard]".length).trim()
|
|
9296
11316
|
}));
|
|
9297
11317
|
}
|
|
9298
|
-
function tooling(rule,
|
|
9299
|
-
return { severity: "error", gate: "check", rule, message };
|
|
11318
|
+
function tooling(rule, message2) {
|
|
11319
|
+
return { severity: "error", gate: "check", rule, message: message2 };
|
|
9300
11320
|
}
|
|
9301
11321
|
function readJson(stdout) {
|
|
9302
11322
|
const first = stdout.indexOf("{");
|
|
@@ -9320,8 +11340,8 @@ function tail(s) {
|
|
|
9320
11340
|
}
|
|
9321
11341
|
|
|
9322
11342
|
// src/verify/fidelity.ts
|
|
9323
|
-
import { readFile as
|
|
9324
|
-
import { join as
|
|
11343
|
+
import { readFile as readFile14 } from "node:fs/promises";
|
|
11344
|
+
import { join as join13 } from "node:path";
|
|
9325
11345
|
|
|
9326
11346
|
// src/verify/typefloor.ts
|
|
9327
11347
|
var TYPE_FLOOR_PX = 40;
|
|
@@ -9375,7 +11395,7 @@ function svgZones(html) {
|
|
|
9375
11395
|
return zones;
|
|
9376
11396
|
}
|
|
9377
11397
|
function userUnit(zones, at) {
|
|
9378
|
-
return zones.filter((
|
|
11398
|
+
return zones.filter((z5) => at >= z5.start && at < z5.end).reduce((unit, z5) => unit * z5.unit, 1);
|
|
9379
11399
|
}
|
|
9380
11400
|
function where(html, at) {
|
|
9381
11401
|
const open = html.lastIndexOf("<", at);
|
|
@@ -9755,21 +11775,21 @@ function captionBottom(sid, selector, fallbackPx) {
|
|
|
9755
11775
|
async function fidelity(dir, opts = {}) {
|
|
9756
11776
|
const started = Date.now();
|
|
9757
11777
|
const floor = opts.floor ?? INK_FLOOR;
|
|
9758
|
-
const notMeasured = (
|
|
11778
|
+
const notMeasured = (why3) => ({
|
|
9759
11779
|
stops: [],
|
|
9760
11780
|
findings: [
|
|
9761
11781
|
{
|
|
9762
11782
|
severity: "warning",
|
|
9763
11783
|
gate: "fidelity",
|
|
9764
11784
|
rule: "not_measured",
|
|
9765
|
-
message: `did not check whether each stop draws anything: ${
|
|
11785
|
+
message: `did not check whether each stop draws anything: ${why3}`
|
|
9766
11786
|
}
|
|
9767
11787
|
],
|
|
9768
11788
|
elapsedMs: Date.now() - started
|
|
9769
11789
|
});
|
|
9770
11790
|
const stops = opts.stops ?? readStops(
|
|
9771
|
-
await
|
|
9772
|
-
await
|
|
11791
|
+
await readFile14(join13(dir, TIMING_FILE), "utf8").catch(() => null),
|
|
11792
|
+
await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => null)
|
|
9773
11793
|
);
|
|
9774
11794
|
if (stops.length === 0) return notMeasured("the deck declares no stops");
|
|
9775
11795
|
let deck = null;
|
|
@@ -9826,10 +11846,10 @@ async function fidelity(dir, opts = {}) {
|
|
|
9826
11846
|
|
|
9827
11847
|
// src/verify/drift.ts
|
|
9828
11848
|
import { execFile as execFile3 } from "node:child_process";
|
|
9829
|
-
import { createHash as
|
|
9830
|
-
import { mkdtemp as mkdtemp3, readdir, readFile as
|
|
11849
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
11850
|
+
import { mkdtemp as mkdtemp3, readdir as readdir2, readFile as readFile15, rm as rm8 } from "node:fs/promises";
|
|
9831
11851
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
9832
|
-
import { join as
|
|
11852
|
+
import { join as join14 } from "node:path";
|
|
9833
11853
|
import { promisify as promisify3 } from "node:util";
|
|
9834
11854
|
var run3 = promisify3(execFile3);
|
|
9835
11855
|
var FLOOR_DB = 40;
|
|
@@ -9868,13 +11888,13 @@ function measureMotion(hashes, scenes, seconds) {
|
|
|
9868
11888
|
async function drift(dir, opts = {}) {
|
|
9869
11889
|
const mode = opts.mode ?? "psnr";
|
|
9870
11890
|
const floorDb = opts.floorDb ?? FLOOR_DB;
|
|
9871
|
-
const work = opts.workDir ?? await mkdtemp3(
|
|
9872
|
-
const a =
|
|
9873
|
-
const b =
|
|
11891
|
+
const work = opts.workDir ?? await mkdtemp3(join14(tmpdir3(), "decksmith-drift-"));
|
|
11892
|
+
const a = join14(work, "a");
|
|
11893
|
+
const b = join14(work, "b");
|
|
9874
11894
|
const report2 = await compare(dir, a, b, mode, floorDb, opts);
|
|
9875
11895
|
const keep = opts.keep || !report2.passed;
|
|
9876
11896
|
if (keep) report2.kept = { a, b };
|
|
9877
|
-
else if (!opts.workDir) await
|
|
11897
|
+
else if (!opts.workDir) await rm8(work, { recursive: true, force: true });
|
|
9878
11898
|
return report2;
|
|
9879
11899
|
}
|
|
9880
11900
|
async function compare(dir, a, b, mode, floorDb, opts) {
|
|
@@ -9889,11 +11909,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
|
|
|
9889
11909
|
[a, "first"],
|
|
9890
11910
|
[b, "second"]
|
|
9891
11911
|
].entries()) {
|
|
9892
|
-
const
|
|
9893
|
-
if (
|
|
11912
|
+
const why3 = await render2(dir, out, { ...opts, workers: counts[i] });
|
|
11913
|
+
if (why3)
|
|
9894
11914
|
return {
|
|
9895
11915
|
passed: false,
|
|
9896
|
-
findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${
|
|
11916
|
+
findings: [finding("render_failed", `The ${label} render of ${dir} failed: ${why3}`)],
|
|
9897
11917
|
...empty
|
|
9898
11918
|
};
|
|
9899
11919
|
}
|
|
@@ -9925,11 +11945,11 @@ async function compare(dir, a, b, mode, floorDb, opts) {
|
|
|
9925
11945
|
const differing = [];
|
|
9926
11946
|
const ha = [];
|
|
9927
11947
|
for (const [i, name] of fa.entries()) {
|
|
9928
|
-
const one = await sha(
|
|
11948
|
+
const one = await sha(join14(a, name));
|
|
9929
11949
|
ha.push(one);
|
|
9930
|
-
if (one !== await sha(
|
|
11950
|
+
if (one !== await sha(join14(b, name))) differing.push(i + 1);
|
|
9931
11951
|
}
|
|
9932
|
-
const html = await
|
|
11952
|
+
const html = await readFile15(join14(dir, COMPOSITION_PAGE), "utf8").catch(() => "");
|
|
9933
11953
|
const motion = measureMotion(ha, readScenes(html), readCanvas(html)?.seconds ?? 0);
|
|
9934
11954
|
let worst;
|
|
9935
11955
|
if (differing.length > 0) {
|
|
@@ -10040,11 +12060,11 @@ async function render2(dir, out, opts) {
|
|
|
10040
12060
|
}
|
|
10041
12061
|
}
|
|
10042
12062
|
async function frames(dir) {
|
|
10043
|
-
const names = await
|
|
12063
|
+
const names = await readdir2(dir).catch(() => []);
|
|
10044
12064
|
return names.filter((n3) => n3.endsWith(".png")).sort();
|
|
10045
12065
|
}
|
|
10046
12066
|
async function sha(file) {
|
|
10047
|
-
return
|
|
12067
|
+
return createHash9("sha256").update(await readFile15(file)).digest("hex");
|
|
10048
12068
|
}
|
|
10049
12069
|
var STAT = /^n:(\d+)\b.*\bpsnr_avg:(inf|-?[\d.]+)/;
|
|
10050
12070
|
function framePattern(name) {
|
|
@@ -10077,11 +12097,11 @@ async function psnr(a, b, firstFrame) {
|
|
|
10077
12097
|
"-start_number",
|
|
10078
12098
|
String(seq.start),
|
|
10079
12099
|
"-i",
|
|
10080
|
-
|
|
12100
|
+
join14(a, seq.pattern),
|
|
10081
12101
|
"-start_number",
|
|
10082
12102
|
String(seq.start),
|
|
10083
12103
|
"-i",
|
|
10084
|
-
|
|
12104
|
+
join14(b, seq.pattern),
|
|
10085
12105
|
"-lavfi",
|
|
10086
12106
|
"psnr=stats_file=-",
|
|
10087
12107
|
"-f",
|
|
@@ -10123,11 +12143,11 @@ function moved(motion) {
|
|
|
10123
12143
|
const rest = motion.unmeasured.length > 0 ? ` ${motion.unmeasured.length} scene(s) had too few frames of their own to judge: ${motion.unmeasured.join(", ")}.` : "";
|
|
10124
12144
|
return ` All ${measured} measurable scene(s) moved within their own window.${rest}`;
|
|
10125
12145
|
}
|
|
10126
|
-
function finding(rule,
|
|
10127
|
-
return { severity: "error", gate: "drift", rule, message };
|
|
12146
|
+
function finding(rule, message2) {
|
|
12147
|
+
return { severity: "error", gate: "drift", rule, message: message2 };
|
|
10128
12148
|
}
|
|
10129
|
-
function note(
|
|
10130
|
-
return { severity: "info", gate: "drift", rule: "stable", message };
|
|
12149
|
+
function note(message2) {
|
|
12150
|
+
return { severity: "info", gate: "drift", rule: "stable", message: message2 };
|
|
10131
12151
|
}
|
|
10132
12152
|
function tail2(s) {
|
|
10133
12153
|
return s.trim().split("\n").slice(-3).join(" / ").slice(0, 400);
|
|
@@ -10135,24 +12155,34 @@ function tail2(s) {
|
|
|
10135
12155
|
|
|
10136
12156
|
// src/verify/index.ts
|
|
10137
12157
|
var NONDETERMINISM = [
|
|
10138
|
-
[/\bMath\.random\s*\(/, "math_random"],
|
|
10139
|
-
[/\bDate\.now\s*\(/, "date_now"],
|
|
10140
|
-
[/\bnew\s+Date\s*\(\s*\)/, "date_now"],
|
|
10141
|
-
[/\bperformance\.now\s*\(/, "performance_now"],
|
|
10142
|
-
[/\bfetch\s*\(/, "runtime_fetch"],
|
|
10143
|
-
[/\bXMLHttpRequest\b/, "runtime_fetch"]
|
|
12158
|
+
[/\bMath\.random\s*\(/, "math_random", "calls"],
|
|
12159
|
+
[/\bDate\.now\s*\(/, "date_now", "calls"],
|
|
12160
|
+
[/\bnew\s+Date\s*\(\s*\)/, "date_now", "calls"],
|
|
12161
|
+
[/\bperformance\.now\s*\(/, "performance_now", "calls"],
|
|
12162
|
+
[/\bfetch\s*\(/, "runtime_fetch", "calls"],
|
|
12163
|
+
[/\bXMLHttpRequest\b/, "runtime_fetch", "calls"],
|
|
12164
|
+
// The TAG, matched with a word boundary so `<iframes>` and the word "iframe"
|
|
12165
|
+
// in a comment or a `createElement("iframe")` in the wrapper's own runtime are
|
|
12166
|
+
// not it. `deck.html` is never scanned at all (`readCompositions`), which is
|
|
12167
|
+
// what keeps the player's own frame out of this.
|
|
12168
|
+
[
|
|
12169
|
+
/<iframe\b/i,
|
|
12170
|
+
"third_party_iframe",
|
|
12171
|
+
"embeds",
|
|
12172
|
+
"Bake what the frame was showing into the deck instead: a still as a figure, or a clip claim-figure can play."
|
|
12173
|
+
]
|
|
10144
12174
|
];
|
|
10145
12175
|
async function verify(dir, opts = {}, storyboard, kept, source) {
|
|
10146
12176
|
const html = await readCompositions(dir);
|
|
10147
12177
|
const determinism = html.flatMap(([file, text2]) => scanDeterminism(text2, file));
|
|
10148
12178
|
const narration = scanNarration(
|
|
10149
|
-
await
|
|
12179
|
+
await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => ""),
|
|
10150
12180
|
await listFiles(dir)
|
|
10151
12181
|
);
|
|
10152
|
-
const
|
|
12182
|
+
const budget3 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
|
|
10153
12183
|
const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
|
|
10154
12184
|
const lead = kept ? await readTiming2(dir).then((t2) => t2 ? scanNarrationLead(kept, t2) : []) : [];
|
|
10155
|
-
const ours = [...determinism, ...narration, ...
|
|
12185
|
+
const ours = [...determinism, ...narration, ...budget3, ...type, ...lead];
|
|
10156
12186
|
const stops = await declaredStops(dir);
|
|
10157
12187
|
const [verdict, frames2] = await Promise.all([
|
|
10158
12188
|
check2(dir, { ...opts, at: stops.map((s) => s.t) }),
|
|
@@ -10183,8 +12213,8 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
|
|
|
10183
12213
|
}
|
|
10184
12214
|
async function declaredStops(dir) {
|
|
10185
12215
|
return readStops(
|
|
10186
|
-
await
|
|
10187
|
-
await
|
|
12216
|
+
await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => null),
|
|
12217
|
+
await readFile16(join15(dir, DECK_PAGE), "utf8").catch(() => null)
|
|
10188
12218
|
);
|
|
10189
12219
|
}
|
|
10190
12220
|
var NARRATION_ISLAND = /<script type="application\/decksmith-narration\+json">([\s\S]*?)<\/script>/;
|
|
@@ -10207,8 +12237,8 @@ function scanNarration(page, files) {
|
|
|
10207
12237
|
const dir = typeof parsed.dir === "string" && parsed.dir ? `${parsed.dir}/` : "";
|
|
10208
12238
|
const scenes = parsed.scenes ?? {};
|
|
10209
12239
|
const missing = /* @__PURE__ */ new Set();
|
|
10210
|
-
for (const
|
|
10211
|
-
for (const s of
|
|
12240
|
+
for (const segments2 of Object.values(scenes)) {
|
|
12241
|
+
for (const s of segments2 ?? []) {
|
|
10212
12242
|
if (typeof s?.audio !== "string" || /^[a-z][a-z0-9+.-]*:|^\//i.test(s.audio)) continue;
|
|
10213
12243
|
if (!files.has(`${dir}${s.audio}`)) missing.add(s.audio);
|
|
10214
12244
|
}
|
|
@@ -10224,7 +12254,7 @@ function scanNarration(page, files) {
|
|
|
10224
12254
|
];
|
|
10225
12255
|
}
|
|
10226
12256
|
async function readTiming2(dir) {
|
|
10227
|
-
const raw2 = await
|
|
12257
|
+
const raw2 = await readFile16(join15(dir, TIMING_FILE), "utf8").catch(() => "");
|
|
10228
12258
|
if (!raw2) return void 0;
|
|
10229
12259
|
try {
|
|
10230
12260
|
const parsed = JSON.parse(raw2);
|
|
@@ -10234,9 +12264,9 @@ async function readTiming2(dir) {
|
|
|
10234
12264
|
}
|
|
10235
12265
|
}
|
|
10236
12266
|
async function listFiles(dir) {
|
|
10237
|
-
const entries = await
|
|
12267
|
+
const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
10238
12268
|
return new Set(
|
|
10239
|
-
entries.filter((e) => e.isFile()).map((e) => relative(dir,
|
|
12269
|
+
entries.filter((e) => e.isFile()).map((e) => relative(dir, join15(e.parentPath, e.name)).split(sep).join("/"))
|
|
10240
12270
|
);
|
|
10241
12271
|
}
|
|
10242
12272
|
var INSTEAD = {
|
|
@@ -10362,20 +12392,20 @@ function scanBeatCount(storyboard, prefs) {
|
|
|
10362
12392
|
];
|
|
10363
12393
|
}
|
|
10364
12394
|
function scanPaperArc(storyboard, prefs) {
|
|
10365
|
-
return arcProblems(storyboard, prefs).map((
|
|
12395
|
+
return arcProblems(storyboard, prefs).map((message2) => ({
|
|
10366
12396
|
severity: "warning",
|
|
10367
12397
|
gate: "storyboard",
|
|
10368
12398
|
rule: "paper_arc",
|
|
10369
|
-
message
|
|
12399
|
+
message: message2
|
|
10370
12400
|
}));
|
|
10371
12401
|
}
|
|
10372
12402
|
function scanNarrationDrift(storyboard, narration) {
|
|
10373
12403
|
const flat = (s) => (s ?? "").replace(/\s+/g, " ").trim();
|
|
10374
12404
|
const stale = [];
|
|
10375
12405
|
for (const beat of storyboard.beats) {
|
|
10376
|
-
const
|
|
10377
|
-
if (!
|
|
10378
|
-
if (flat(
|
|
12406
|
+
const segments2 = narration.beats[beat.id];
|
|
12407
|
+
if (!segments2?.length || !flat(beat.narration)) continue;
|
|
12408
|
+
if (flat(segments2.map((s) => s.text).join(" ")) !== flat(beat.narration)) stale.push(beat.id);
|
|
10379
12409
|
}
|
|
10380
12410
|
if (!stale.length) return [];
|
|
10381
12411
|
return [
|
|
@@ -10420,8 +12450,8 @@ function scanNarrationLead(beats, timing) {
|
|
|
10420
12450
|
}
|
|
10421
12451
|
return findings;
|
|
10422
12452
|
}
|
|
10423
|
-
function firstMention(
|
|
10424
|
-
for (const segment of
|
|
12453
|
+
function firstMention(segments2, label) {
|
|
12454
|
+
for (const segment of segments2) {
|
|
10425
12455
|
for (const cue of segment.cues) {
|
|
10426
12456
|
const words2 = cue.text.split(/\s+/);
|
|
10427
12457
|
let at = 0;
|
|
@@ -10479,32 +12509,28 @@ var STOP = /* @__PURE__ */ new Set([
|
|
|
10479
12509
|
function scanDeterminism(html, file) {
|
|
10480
12510
|
const findings = [];
|
|
10481
12511
|
const lines = html.split("\n");
|
|
10482
|
-
for (const [pattern, rule] of NONDETERMINISM) {
|
|
12512
|
+
for (const [pattern, rule, verb, remedy2] of NONDETERMINISM) {
|
|
10483
12513
|
const i = lines.findIndex((line2) => pattern.test(line2));
|
|
10484
12514
|
if (i < 0) continue;
|
|
10485
12515
|
findings.push({
|
|
10486
12516
|
severity: "error",
|
|
10487
12517
|
gate: "determinism",
|
|
10488
12518
|
rule,
|
|
10489
|
-
message: `${file}:${i + 1}
|
|
12519
|
+
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}` : "")
|
|
10490
12520
|
});
|
|
10491
12521
|
}
|
|
10492
12522
|
return findings;
|
|
10493
12523
|
}
|
|
10494
12524
|
async function readCompositions(dir) {
|
|
10495
|
-
const entries = await
|
|
12525
|
+
const entries = await readdir3(dir, { recursive: true, withFileTypes: true }).catch(() => []);
|
|
10496
12526
|
const files = entries.filter(
|
|
10497
12527
|
(e) => e.isFile() && e.name.endsWith(".html") && e.name !== DECK_PAGE && !e.parentPath.includes("node_modules")
|
|
10498
|
-
).map((e) =>
|
|
12528
|
+
).map((e) => join15(e.parentPath, e.name));
|
|
10499
12529
|
return Promise.all(
|
|
10500
|
-
files.map(async (f) => [relative(dir, f), await
|
|
12530
|
+
files.map(async (f) => [relative(dir, f), await readFile16(f, "utf8")])
|
|
10501
12531
|
);
|
|
10502
12532
|
}
|
|
10503
12533
|
|
|
10504
|
-
// src/version.ts
|
|
10505
|
-
import { createRequire as createRequire2 } from "node:module";
|
|
10506
|
-
var VERSION = createRequire2(import.meta.url)("../package.json").version;
|
|
10507
|
-
|
|
10508
12534
|
// src/cli.ts
|
|
10509
12535
|
var AUDIO_DIR = "audio";
|
|
10510
12536
|
var NARRATION_FILE = "narration.json";
|
|
@@ -10512,7 +12538,7 @@ var DEFAULTS = prefsSchema.parse({});
|
|
|
10512
12538
|
async function deckRuntime() {
|
|
10513
12539
|
const path2 = fileURLToPath(new URL(`./${"deck-runtime.js"}`, import.meta.url));
|
|
10514
12540
|
try {
|
|
10515
|
-
return await
|
|
12541
|
+
return await readFile17(path2, "utf8");
|
|
10516
12542
|
} catch {
|
|
10517
12543
|
throw new Error(`Deck runtime missing at ${path2}. Run "npm run build" first.`);
|
|
10518
12544
|
}
|
|
@@ -10520,40 +12546,40 @@ async function deckRuntime() {
|
|
|
10520
12546
|
function playerBundle() {
|
|
10521
12547
|
const require2 = createRequire3(import.meta.url);
|
|
10522
12548
|
try {
|
|
10523
|
-
return
|
|
12549
|
+
return join16(dirname4(require2.resolve("hyperframes/package.json")), "dist", PLAYER_FILE);
|
|
10524
12550
|
} catch {
|
|
10525
12551
|
throw new Error('Cannot locate the hyperframes player. Run "npm install".');
|
|
10526
12552
|
}
|
|
10527
12553
|
}
|
|
10528
12554
|
async function vendorKatex(out) {
|
|
10529
12555
|
const require2 = createRequire3(import.meta.url);
|
|
10530
|
-
const dist =
|
|
10531
|
-
const css = await
|
|
10532
|
-
await
|
|
10533
|
-
for (const file of await
|
|
12556
|
+
const dist = join16(dirname4(require2.resolve("katex/package.json")), "dist");
|
|
12557
|
+
const css = await readFile17(join16(dist, "katex.min.css"), "utf8");
|
|
12558
|
+
await mkdir10(join16(out, "katex/fonts"), { recursive: true });
|
|
12559
|
+
for (const file of await readdir4(join16(dist, "fonts"))) {
|
|
10534
12560
|
if (file.endsWith(".woff2"))
|
|
10535
|
-
await cp(
|
|
12561
|
+
await cp(join16(dist, "fonts", file), join16(out, "katex/fonts", file));
|
|
10536
12562
|
}
|
|
10537
12563
|
const woff2Only = css.replace(/src:([^;}]*)/g, (whole, list) => {
|
|
10538
12564
|
const kept = list.split(",").filter((part) => part.includes(".woff2")).join(",");
|
|
10539
12565
|
return kept ? `src:${kept}` : whole;
|
|
10540
12566
|
});
|
|
10541
|
-
await
|
|
12567
|
+
await writeFile12(join16(out, "katex/katex.min.css"), woff2Only);
|
|
10542
12568
|
}
|
|
10543
12569
|
async function vendorScripts(out) {
|
|
10544
12570
|
const require2 = createRequire3(import.meta.url);
|
|
10545
|
-
await
|
|
12571
|
+
await mkdir10(join16(out, "vendor"), { recursive: true });
|
|
10546
12572
|
for (const [pkg, rel, name] of [
|
|
10547
12573
|
["gsap/package.json", "dist/gsap.min.js", "gsap.min.js"],
|
|
10548
12574
|
["gsap/package.json", "dist/DrawSVGPlugin.min.js", "DrawSVGPlugin.min.js"],
|
|
10549
12575
|
["katex/package.json", "dist/katex.min.js", "katex.min.js"]
|
|
10550
12576
|
]) {
|
|
10551
|
-
const from =
|
|
10552
|
-
await cp(from,
|
|
12577
|
+
const from = join16(dirname4(require2.resolve(pkg)), rel);
|
|
12578
|
+
await cp(from, join16(out, "vendor", name));
|
|
10553
12579
|
}
|
|
10554
12580
|
await cp(
|
|
10555
12581
|
fileURLToPath(new URL("./ds-morph.js", import.meta.url)),
|
|
10556
|
-
|
|
12582
|
+
join16(out, "vendor", "ds-morph.js")
|
|
10557
12583
|
);
|
|
10558
12584
|
}
|
|
10559
12585
|
var HYPERFRAMES_JSON = `${JSON.stringify(
|
|
@@ -10619,22 +12645,78 @@ function flags(o) {
|
|
|
10619
12645
|
function stated(prefs, key) {
|
|
10620
12646
|
return prefs[key] === DEFAULTS[key] ? void 0 : prefs[key];
|
|
10621
12647
|
}
|
|
12648
|
+
function url(input) {
|
|
12649
|
+
return /^https?:\/\//i.test(input);
|
|
12650
|
+
}
|
|
12651
|
+
function budget2(o) {
|
|
12652
|
+
const maxAssets = positive(o.maxAssets, "--max-assets");
|
|
12653
|
+
const maxClips = positive(o.maxClips, "--max-clips");
|
|
12654
|
+
const maxTotalBytes = positive(o.maxBytes, "--max-bytes");
|
|
12655
|
+
const seconds = positive(o.maxSeconds, "--max-seconds");
|
|
12656
|
+
const maxClipSeconds = positive(o.maxClipSeconds, "--max-clip-seconds");
|
|
12657
|
+
return {
|
|
12658
|
+
...maxAssets === void 0 ? {} : { maxAssets },
|
|
12659
|
+
...maxClips === void 0 ? {} : { maxClips },
|
|
12660
|
+
...maxTotalBytes === void 0 ? {} : { maxTotalBytes },
|
|
12661
|
+
...seconds === void 0 ? {} : { maxWallMs: seconds * 1e3 },
|
|
12662
|
+
...maxClipSeconds === void 0 ? {} : { maxClipSeconds },
|
|
12663
|
+
// Only the negative travels. Commander fills `--no-` flags in as `true`, and
|
|
12664
|
+
// passing that through would state a default in this file that belongs to
|
|
12665
|
+
// `harvest` — which is also what the server and the MCP call.
|
|
12666
|
+
...o.transcode === false ? { transcode: false } : {}
|
|
12667
|
+
};
|
|
12668
|
+
}
|
|
12669
|
+
function positive(value, flag) {
|
|
12670
|
+
if (value === void 0) return void 0;
|
|
12671
|
+
const n3 = Number(value);
|
|
12672
|
+
if (!Number.isFinite(n3) || n3 <= 0) {
|
|
12673
|
+
throw new Error(`${flag} takes a positive number; got "${value}".`);
|
|
12674
|
+
}
|
|
12675
|
+
return n3;
|
|
12676
|
+
}
|
|
10622
12677
|
var program = new Command();
|
|
10623
12678
|
program.name("decksmith").description("Turn a source document into an animated explanation deck.").version(VERSION);
|
|
10624
|
-
program.command("ingest").description("Parse a document into source.json, localising its figures and font.").argument("<input>", "
|
|
10625
|
-
|
|
10626
|
-
|
|
10627
|
-
|
|
10628
|
-
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
);
|
|
10633
|
-
|
|
10634
|
-
|
|
10635
|
-
|
|
10636
|
-
|
|
10637
|
-
|
|
12679
|
+
program.command("ingest").description("Parse a document or a web page into source.json, localising its figures and font.").argument("<input>", "the document: a markdown file, or an http(s) URL to read the page at").requiredOption("-o, --out <file>", "where to write source.json").option("--lang <bcp47>", "override the sniffed language").option("--max-assets <n>", "URL only: figures to download (maxAssets; default 40)").option("--max-clips <n>", "URL only: videos to download as clips (maxClips; default 4)").option("--max-bytes <n>", "URL only: bytes to download in total (maxTotalBytes; default 96 MB)").option(
|
|
12680
|
+
"--max-seconds <n>",
|
|
12681
|
+
"URL only: wall clock for the whole harvest (maxWallMs; default 180)"
|
|
12682
|
+
).option(
|
|
12683
|
+
"--max-clip-seconds <n>",
|
|
12684
|
+
"URL only: seconds of each clip kept, the rest trimmed (maxClipSeconds; default 60)"
|
|
12685
|
+
).option("--no-transcode", "URL only: ship each clip as the page served it, unshrunk").action(async (input, o) => {
|
|
12686
|
+
const assets = join16(dirname4(resolve5(o.out)), "assets");
|
|
12687
|
+
const scratch = url(input) ? await mkdtemp4(join16(tmpdir4(), "decksmith-harvest-")) : void 0;
|
|
12688
|
+
try {
|
|
12689
|
+
let md;
|
|
12690
|
+
let clips = [];
|
|
12691
|
+
if (scratch === void 0) {
|
|
12692
|
+
md = await readFile17(resolve5(input), "utf8").catch(() => {
|
|
12693
|
+
throw new Error(`Cannot read ${input}.`);
|
|
12694
|
+
});
|
|
12695
|
+
} else {
|
|
12696
|
+
step(`ingest: reading ${input} \u2014 this opens a browser and takes a moment`);
|
|
12697
|
+
const page = await harvest(input, scratch, budget2(o));
|
|
12698
|
+
for (const warning of page.warnings) step(`ingest: ${warning}`);
|
|
12699
|
+
step(
|
|
12700
|
+
`ingest: harvested "${page.title}" \u2014 ${page.assets.length} images, ${page.clips.length} clips`
|
|
12701
|
+
);
|
|
12702
|
+
md = page.markdown;
|
|
12703
|
+
clips = page.clips;
|
|
12704
|
+
}
|
|
12705
|
+
const parsed = parseMarkdown(md, { lang: o.lang });
|
|
12706
|
+
step(
|
|
12707
|
+
`ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations`
|
|
12708
|
+
);
|
|
12709
|
+
const withClips = await attachClips(parsed, clips, assets);
|
|
12710
|
+
const dropped = [];
|
|
12711
|
+
const source = await fetchFigures(withClips, assets, dropped);
|
|
12712
|
+
for (const why3 of dropped) step(`ingest: ${why3}`);
|
|
12713
|
+
const bundle = await bundleFont(source.lang, glyphs(source), join16(assets, "fonts"));
|
|
12714
|
+
if (bundle) step(`ingest: bundled ${bundle.family} for ${source.lang}`);
|
|
12715
|
+
await writeJson(o.out, source);
|
|
12716
|
+
step(`ingest: wrote ${o.out}`);
|
|
12717
|
+
} finally {
|
|
12718
|
+
if (scratch !== void 0) await rm9(scratch, { recursive: true, force: true });
|
|
12719
|
+
}
|
|
10638
12720
|
});
|
|
10639
12721
|
imageFlags(
|
|
10640
12722
|
lookFlags(
|
|
@@ -10689,7 +12771,7 @@ imageFlags(
|
|
|
10689
12771
|
program.command("illustrate").description("Draw the pictures the plan asked for, and point the storyboard at them.").argument("<storyboard>", "storyboard.json carrying illustration briefs").requiredOption("--source <file>", "source.json the storyboard was planned from")
|
|
10690
12772
|
).action(async (sbPath, o) => {
|
|
10691
12773
|
const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
|
|
10692
|
-
const sourcePath =
|
|
12774
|
+
const sourcePath = resolve5(String(o.source));
|
|
10693
12775
|
const source = await readValidated(sourcePath, sourceSchema, "source");
|
|
10694
12776
|
assertRefsResolve(storyboard, source, { pending: "allow" });
|
|
10695
12777
|
const prefs = await loadPrefs(prefsFromFlags({ ...flags(o), images: true }));
|
|
@@ -10703,7 +12785,7 @@ imageFlags(
|
|
|
10703
12785
|
);
|
|
10704
12786
|
const drawn = await illustrate(storyboard, source, {
|
|
10705
12787
|
prefs,
|
|
10706
|
-
assetsDir:
|
|
12788
|
+
assetsDir: join16(dirname4(sourcePath), "assets"),
|
|
10707
12789
|
onStep: step
|
|
10708
12790
|
});
|
|
10709
12791
|
for (const p of drawn.illustrated) {
|
|
@@ -10728,18 +12810,18 @@ voiceFlags(
|
|
|
10728
12810
|
const format = pickFormat(String(o.format), o.width, o.height);
|
|
10729
12811
|
const chosen = await loadPrefs(prefsFromFlags(flags(o)));
|
|
10730
12812
|
const prefs = stated(chosen, "lang") ? chosen : { ...chosen, lang: storyboard.lang };
|
|
10731
|
-
const dir =
|
|
12813
|
+
const dir = resolve5(String(o.out));
|
|
10732
12814
|
const speaking = storyboard.beats.filter((b) => b.narration?.trim()).length;
|
|
10733
12815
|
if (speaking === 0) {
|
|
10734
12816
|
throw new Error(`No beat in ${sbPath} has a "narration" field, so there is nothing to speak.`);
|
|
10735
12817
|
}
|
|
10736
12818
|
step(`narrate: ${speaking} of ${storyboard.beats.length} beats have narration`);
|
|
10737
12819
|
const narration = await narrate(storyboard, source, prefs, { dir, format });
|
|
10738
|
-
await writeJson(
|
|
10739
|
-
const
|
|
10740
|
-
const seconds =
|
|
12820
|
+
await writeJson(join16(dir, NARRATION_FILE), narration);
|
|
12821
|
+
const segments2 = Object.values(narration.beats).flat();
|
|
12822
|
+
const seconds = segments2.reduce((sum, s) => sum + s.seconds, 0);
|
|
10741
12823
|
step(
|
|
10742
|
-
`narrate: ${
|
|
12824
|
+
`narrate: ${segments2.length} segments, ${seconds.toFixed(1)}s in ${narration.voice} \u2192 ${join16(dir, NARRATION_FILE)}`
|
|
10743
12825
|
);
|
|
10744
12826
|
});
|
|
10745
12827
|
lookFlags(
|
|
@@ -10754,8 +12836,8 @@ lookFlags(
|
|
|
10754
12836
|
const format = withMinWeight(pickFormat(o.format, o.width, o.height), o.minWeight);
|
|
10755
12837
|
const prefs = await loadPrefs(prefsFromFlags(flags(o)), process.cwd(), source);
|
|
10756
12838
|
const theme = stated(prefs, "theme") ?? storyboard.theme;
|
|
10757
|
-
const out =
|
|
10758
|
-
await
|
|
12839
|
+
const out = resolve5(o.out);
|
|
12840
|
+
await mkdir10(out, { recursive: true });
|
|
10759
12841
|
const found = await findNarration(sbPath, o.narration);
|
|
10760
12842
|
const narration = found ? await loadNarration(found) : void 0;
|
|
10761
12843
|
if (narration) {
|
|
@@ -10781,8 +12863,8 @@ lookFlags(
|
|
|
10781
12863
|
// keeps finding by eye.
|
|
10782
12864
|
onBeatError: (id2, err) => step(`build: left out ${id2} \u2014 ${err.message}`)
|
|
10783
12865
|
});
|
|
10784
|
-
await
|
|
10785
|
-
await
|
|
12866
|
+
await writeFile12(join16(out, "index.html"), deck.composition);
|
|
12867
|
+
await writeFile12(join16(out, "hyperframes.json"), HYPERFRAMES_JSON);
|
|
10786
12868
|
await writeTiming(out, {
|
|
10787
12869
|
storyboard,
|
|
10788
12870
|
source,
|
|
@@ -10798,36 +12880,36 @@ lookFlags(
|
|
|
10798
12880
|
...narration ? { narration } : {}
|
|
10799
12881
|
});
|
|
10800
12882
|
if (deck.page) {
|
|
10801
|
-
await
|
|
10802
|
-
await cp(playerBundle(),
|
|
12883
|
+
await writeFile12(join16(out, DECK_PAGE), deck.page);
|
|
12884
|
+
await cp(playerBundle(), join16(out, PLAYER_FILE));
|
|
10803
12885
|
}
|
|
10804
12886
|
await vendorKatex(out);
|
|
10805
12887
|
await vendorScripts(out);
|
|
10806
|
-
await copyAssets(dirname4(
|
|
12888
|
+
await copyAssets(dirname4(resolve5(o.source)), out, source.figures);
|
|
10807
12889
|
if (found && narration) await copyAudio(dirname4(found), narration, out);
|
|
10808
12890
|
const look = [theme, paced.speed === 1 ? "" : `${paced.speed}\xD7 speed`].filter(Boolean).join(", ");
|
|
10809
12891
|
const { cut } = deck;
|
|
10810
12892
|
const floor = cut.dropped.filter((d) => d.rule === "below_min_weight").length;
|
|
10811
12893
|
const of = cut.kept.length === storyboard.beats.length ? "" : ` of ${storyboard.beats.length}`;
|
|
10812
12894
|
step(
|
|
10813
|
-
`build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${
|
|
12895
|
+
`build: ${cut.kept.length}${of} beats at ${format.width}\xD7${format.height} in ${look}${floor > 0 ? ` (${floor} below minWeight ${format.minWeight})` : ""} \u2192 ${join16(out, "index.html")}`
|
|
10814
12896
|
);
|
|
10815
12897
|
for (const f of scanPaperArc({ ...storyboard, beats: cut.kept }, prefs))
|
|
10816
12898
|
step(`build: ${f.message}`);
|
|
10817
12899
|
reportCut(cut);
|
|
10818
|
-
if (deck.page) step(`build: navigable deck \u2192 ${
|
|
12900
|
+
if (deck.page) step(`build: navigable deck \u2192 ${join16(out, DECK_PAGE)}`);
|
|
10819
12901
|
await gate(out, false, storyboard, cut.kept, o.fidelity !== false, source);
|
|
10820
12902
|
}
|
|
10821
12903
|
);
|
|
10822
12904
|
program.command("verify").description("Re-run the gates on a built deck.").argument("<dir>", "a built deck directory").option("--snapshots", "also write the contrast-pass PNGs to <dir>/snapshots", false).option("--no-fidelity", "skip the frame check \u2014 only for a machine with no browser").action(async (dir, o) => {
|
|
10823
|
-
await gate(
|
|
12905
|
+
await gate(resolve5(dir), o.snapshots, void 0, void 0, o.fidelity !== false);
|
|
10824
12906
|
});
|
|
10825
12907
|
program.command("frames").description("Write PNGs of a built deck through the capture path the renderer uses.").argument("<dir>", "a built deck directory").option(
|
|
10826
12908
|
"--at <seconds...>",
|
|
10827
12909
|
"absolute times to photograph; defaults to every hold the deck declares"
|
|
10828
12910
|
).option("--out <dir>", "where to write the PNGs (default: <dir>/frames)").action(async (dir, o) => {
|
|
10829
|
-
const deck =
|
|
10830
|
-
const out =
|
|
12911
|
+
const deck = resolve5(dir);
|
|
12912
|
+
const out = resolve5(o.out ?? join16(deck, "frames"));
|
|
10831
12913
|
let times;
|
|
10832
12914
|
if (o.at?.length) {
|
|
10833
12915
|
times = o.at.map((raw2) => {
|
|
@@ -10837,8 +12919,8 @@ program.command("frames").description("Write PNGs of a built deck through the ca
|
|
|
10837
12919
|
});
|
|
10838
12920
|
} else {
|
|
10839
12921
|
times = readStops(
|
|
10840
|
-
await
|
|
10841
|
-
await
|
|
12922
|
+
await readFile17(join16(deck, TIMING_FILE), "utf8").catch(() => null),
|
|
12923
|
+
await readFile17(join16(deck, DECK_PAGE), "utf8").catch(() => null)
|
|
10842
12924
|
).map((s) => s.t);
|
|
10843
12925
|
if (times.length === 0)
|
|
10844
12926
|
throw new Error(
|
|
@@ -10852,7 +12934,7 @@ program.command("frames").description("Write PNGs of a built deck through the ca
|
|
|
10852
12934
|
program.command("drift").description("Render a built deck twice and compare, frame by frame. Minutes, not seconds.").argument("<dir>", "a built deck directory").option("--identical", "require every frame byte-identical \u2014 image-free decks only", false).option("--floor <dB>", "per-frame PSNR floor", String(FLOOR_DB)).option("--keep", "keep both frame directories even when they agree").option("--workers <n>", "pin both renders to one worker count (default: 1 vs 3)").action(
|
|
10853
12935
|
async (dir, o) => {
|
|
10854
12936
|
step("drift: rendering twice, several minutes a render");
|
|
10855
|
-
const verdict = await drift(
|
|
12937
|
+
const verdict = await drift(resolve5(dir), {
|
|
10856
12938
|
mode: o.identical ? "identical" : "psnr",
|
|
10857
12939
|
floorDb: Number(o.floor),
|
|
10858
12940
|
...o.keep ? { keep: true } : {},
|
|
@@ -10879,8 +12961,8 @@ program.command("render").description("Render a built deck to a finished video:
|
|
|
10879
12961
|
throw new Error(`Unknown --subtitles "${o.subtitles}". Use sidecar, burn or none.`);
|
|
10880
12962
|
}
|
|
10881
12963
|
const result = await render({
|
|
10882
|
-
deck:
|
|
10883
|
-
out:
|
|
12964
|
+
deck: resolve5(dir),
|
|
12965
|
+
out: resolve5(o.out),
|
|
10884
12966
|
subtitles: o.subtitles,
|
|
10885
12967
|
protocolTimeoutMs: Number(o.protocolTimeout),
|
|
10886
12968
|
log: step,
|
|
@@ -10908,7 +12990,7 @@ voiceFlags(
|
|
|
10908
12990
|
)
|
|
10909
12991
|
).action(async (sbPath, o) => {
|
|
10910
12992
|
const storyboard = await readValidated(sbPath, storyboardSchema, "storyboard");
|
|
10911
|
-
const sourcePath =
|
|
12993
|
+
const sourcePath = resolve5(String(o.source));
|
|
10912
12994
|
const source = await readValidated(sourcePath, sourceSchema, "source");
|
|
10913
12995
|
assertRefsResolve(storyboard, source);
|
|
10914
12996
|
if (o.bake && o.link) throw new Error("Choose --bake or --link, not both.");
|
|
@@ -10938,7 +13020,7 @@ voiceFlags(
|
|
|
10938
13020
|
...narration ? { narration } : {},
|
|
10939
13021
|
media: plan.media
|
|
10940
13022
|
};
|
|
10941
|
-
const out =
|
|
13023
|
+
const out = resolve5(String(o.out));
|
|
10942
13024
|
const bytes = await writePack(pack2, files, out);
|
|
10943
13025
|
if (plan.demoted.length) {
|
|
10944
13026
|
step(`pack: kept as links, not bakeable \u2014 ${plan.demoted.join(", ")}`);
|
|
@@ -10952,19 +13034,19 @@ voiceFlags(
|
|
|
10952
13034
|
step(`pack: ${size2(bytes)} \u2192 ${out}`);
|
|
10953
13035
|
});
|
|
10954
13036
|
program.command("unpack").description("Open a .deck back into the files build reads.").argument("<file>", "a .deck archive").requiredOption("-o, --out <dir>", "directory to open it into").action(async (file, o) => {
|
|
10955
|
-
const { pack: pack2, files } = await readPack(
|
|
10956
|
-
const out =
|
|
10957
|
-
await writeJson(
|
|
10958
|
-
await writeJson(
|
|
10959
|
-
await writeJson(
|
|
10960
|
-
if (pack2.narration) await writeJson(
|
|
13037
|
+
const { pack: pack2, files } = await readPack(resolve5(file));
|
|
13038
|
+
const out = resolve5(o.out);
|
|
13039
|
+
await writeJson(join16(out, "source.json"), pack2.source);
|
|
13040
|
+
await writeJson(join16(out, "storyboard.json"), pack2.storyboard);
|
|
13041
|
+
await writeJson(join16(out, "decksmith.config.json"), pack2.prefs);
|
|
13042
|
+
if (pack2.narration) await writeJson(join16(out, AUDIO_DIR, NARRATION_FILE), pack2.narration);
|
|
10961
13043
|
const figures = new Map(pack2.source.figures.map((f) => [f.id, f.src]));
|
|
10962
13044
|
for (const [path2, bytes] of Object.entries(files)) {
|
|
10963
13045
|
const media = pack2.media.find((m) => m.path === path2);
|
|
10964
13046
|
const src = media && figures.get(media.id);
|
|
10965
|
-
const to = src ?
|
|
10966
|
-
await
|
|
10967
|
-
await
|
|
13047
|
+
const to = src ? join16("assets", src) : path2;
|
|
13048
|
+
await mkdir10(dirname4(join16(out, to)), { recursive: true });
|
|
13049
|
+
await writeFile12(join16(out, to), bytes);
|
|
10968
13050
|
}
|
|
10969
13051
|
const linked = pack2.media.filter((m) => m.policy !== "bake");
|
|
10970
13052
|
step(`unpack: "${pack2.title}", ${pack2.storyboard.beats.length} beats \u2192 ${out}`);
|
|
@@ -10974,7 +13056,7 @@ program.command("unpack").description("Open a .deck back into the files build re
|
|
|
10974
13056
|
);
|
|
10975
13057
|
}
|
|
10976
13058
|
step(
|
|
10977
|
-
`unpack: decksmith build ${
|
|
13059
|
+
`unpack: decksmith build ${join16(relative2(process.cwd(), out) || ".", "storyboard.json")} --source ${join16(relative2(process.cwd(), out) || ".", "source.json")} -o deck`
|
|
10978
13060
|
);
|
|
10979
13061
|
});
|
|
10980
13062
|
program.parseAsync(process.argv).catch((err) => {
|
|
@@ -10995,7 +13077,7 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
|
|
|
10995
13077
|
);
|
|
10996
13078
|
const verdict = await verify(dir, { snapshots, fidelity: fidelity2 }, storyboard, kept, source);
|
|
10997
13079
|
process.stdout.write(report(verdict));
|
|
10998
|
-
if (snapshots) step(`verify: snapshots in ${
|
|
13080
|
+
if (snapshots) step(`verify: snapshots in ${join16(dir, "snapshots")}`);
|
|
10999
13081
|
if (!verdict.passed) process.exitCode = 1;
|
|
11000
13082
|
}
|
|
11001
13083
|
function report(v) {
|
|
@@ -11012,13 +13094,13 @@ function report(v) {
|
|
|
11012
13094
|
async function findNarration(sbPath, flag) {
|
|
11013
13095
|
if (flag === false) return void 0;
|
|
11014
13096
|
if (typeof flag === "string") {
|
|
11015
|
-
const path2 =
|
|
11016
|
-
if (!await
|
|
13097
|
+
const path2 = resolve5(flag);
|
|
13098
|
+
if (!await stat2(path2).catch(() => null)) throw new Error(`Cannot read narration ${flag}.`);
|
|
11017
13099
|
return path2;
|
|
11018
13100
|
}
|
|
11019
|
-
const beside = dirname4(
|
|
11020
|
-
for (const candidate of [
|
|
11021
|
-
if (await
|
|
13101
|
+
const beside = dirname4(resolve5(sbPath));
|
|
13102
|
+
for (const candidate of [join16(beside, AUDIO_DIR, NARRATION_FILE), join16(beside, NARRATION_FILE)]) {
|
|
13103
|
+
if (await stat2(candidate).catch(() => null)) {
|
|
11022
13104
|
step(`narration: using ${candidate}`);
|
|
11023
13105
|
return candidate;
|
|
11024
13106
|
}
|
|
@@ -11037,11 +13119,11 @@ function audioNames(narration) {
|
|
|
11037
13119
|
].sort();
|
|
11038
13120
|
}
|
|
11039
13121
|
async function copyAudio(from, narration, out) {
|
|
11040
|
-
const dir =
|
|
11041
|
-
await
|
|
13122
|
+
const dir = join16(out, AUDIO_DIR);
|
|
13123
|
+
await mkdir10(dir, { recursive: true });
|
|
11042
13124
|
const names = audioNames(narration);
|
|
11043
13125
|
for (const name of names) {
|
|
11044
|
-
await cp(
|
|
13126
|
+
await cp(join16(from, name), join16(dir, name)).catch(() => {
|
|
11045
13127
|
throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
|
|
11046
13128
|
});
|
|
11047
13129
|
}
|
|
@@ -11050,7 +13132,7 @@ async function copyAudio(from, narration, out) {
|
|
|
11050
13132
|
async function audioFiles(from, narration) {
|
|
11051
13133
|
const files = {};
|
|
11052
13134
|
for (const name of audioNames(narration)) {
|
|
11053
|
-
const bytes = await
|
|
13135
|
+
const bytes = await readFile17(join16(from, name)).catch(() => {
|
|
11054
13136
|
throw new Error(`Narration names ${name}, but it is not in ${from}. Re-run \`narrate\`.`);
|
|
11055
13137
|
});
|
|
11056
13138
|
files[`${AUDIO_DIR}/${name}`] = new Uint8Array(bytes);
|
|
@@ -11072,9 +13154,9 @@ function wrapScript(text2, width) {
|
|
|
11072
13154
|
async function writeTiming(out, input) {
|
|
11073
13155
|
try {
|
|
11074
13156
|
const timing = planTiming(input);
|
|
11075
|
-
await writeJson(
|
|
13157
|
+
await writeJson(join16(out, TIMING_FILE), timing);
|
|
11076
13158
|
step(
|
|
11077
|
-
`build: timing for ${timing.segments.length} narration segment(s) \u2192 ${
|
|
13159
|
+
`build: timing for ${timing.segments.length} narration segment(s) \u2192 ${join16(out, TIMING_FILE)}`
|
|
11078
13160
|
);
|
|
11079
13161
|
} catch (err) {
|
|
11080
13162
|
step(
|
|
@@ -11083,10 +13165,10 @@ async function writeTiming(out, input) {
|
|
|
11083
13165
|
}
|
|
11084
13166
|
}
|
|
11085
13167
|
function figureAssets(source, sourcePath, prefer) {
|
|
11086
|
-
const assets =
|
|
13168
|
+
const assets = join16(dirname4(sourcePath), "assets");
|
|
11087
13169
|
return source.figures.map((f) => ({
|
|
11088
13170
|
id: f.id,
|
|
11089
|
-
url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src :
|
|
13171
|
+
url: /^[a-z][a-z0-9+.-]*:/i.test(f.src) ? f.src : join16(assets, f.src),
|
|
11090
13172
|
prefer
|
|
11091
13173
|
}));
|
|
11092
13174
|
}
|
|
@@ -11115,25 +13197,27 @@ function withMinWeight(format, raw2) {
|
|
|
11115
13197
|
return { ...format, minWeight };
|
|
11116
13198
|
}
|
|
11117
13199
|
async function copyAssets(sourceDir, out, figures) {
|
|
11118
|
-
const from =
|
|
11119
|
-
if (!await
|
|
13200
|
+
const from = join16(sourceDir, "assets");
|
|
13201
|
+
if (!await stat2(from).catch(() => null)) {
|
|
11120
13202
|
step(`build: no assets/ beside source.json, skipping`);
|
|
11121
13203
|
return;
|
|
11122
13204
|
}
|
|
11123
|
-
const wanted = new Set(
|
|
11124
|
-
|
|
13205
|
+
const wanted = new Set(
|
|
13206
|
+
figures.flatMap((f) => [f.src, f.poster]).filter((n3) => n3 !== void 0).map((n3) => n3.replace(/^\.?\//, ""))
|
|
13207
|
+
);
|
|
13208
|
+
await mkdir10(join16(out, "assets"), { recursive: true });
|
|
11125
13209
|
let copied = 0;
|
|
11126
13210
|
for (const name of wanted) {
|
|
11127
|
-
const src =
|
|
11128
|
-
if (!src.startsWith(`${
|
|
11129
|
-
if (!await
|
|
11130
|
-
await
|
|
11131
|
-
await cp(src,
|
|
13211
|
+
const src = resolve5(join16(from, name));
|
|
13212
|
+
if (!src.startsWith(`${resolve5(from)}/`)) continue;
|
|
13213
|
+
if (!await stat2(src).catch(() => null)) continue;
|
|
13214
|
+
await mkdir10(dirname4(join16(out, "assets", name)), { recursive: true });
|
|
13215
|
+
await cp(src, join16(out, "assets", name));
|
|
11132
13216
|
copied++;
|
|
11133
13217
|
}
|
|
11134
|
-
const fonts =
|
|
11135
|
-
if (await
|
|
11136
|
-
await cp(fonts,
|
|
13218
|
+
const fonts = join16(from, "fonts");
|
|
13219
|
+
if (await stat2(fonts).catch(() => null)) {
|
|
13220
|
+
await cp(fonts, join16(out, "assets", "fonts"), { recursive: true });
|
|
11137
13221
|
}
|
|
11138
13222
|
step(`build: copied ${copied} referenced figure(s)`);
|
|
11139
13223
|
}
|
|
@@ -11142,7 +13226,7 @@ async function refreshFont(storyboard, source, out) {
|
|
|
11142
13226
|
const bundle = await bundleFont(
|
|
11143
13227
|
storyboard.lang,
|
|
11144
13228
|
glyphs(source) + glyphs(storyboard),
|
|
11145
|
-
|
|
13229
|
+
join16(out, "assets", "fonts")
|
|
11146
13230
|
);
|
|
11147
13231
|
if (bundle) step(`build: font bundle covers ${bundle.family}`);
|
|
11148
13232
|
return bundle?.css;
|
|
@@ -11157,7 +13241,7 @@ function glyphs(value) {
|
|
|
11157
13241
|
return JSON.stringify(value);
|
|
11158
13242
|
}
|
|
11159
13243
|
async function readValidated(file, schema, label) {
|
|
11160
|
-
const text2 = await
|
|
13244
|
+
const text2 = await readFile17(resolve5(file), "utf8").catch(() => {
|
|
11161
13245
|
throw new Error(`Cannot read ${label} file ${file}.`);
|
|
11162
13246
|
});
|
|
11163
13247
|
let json;
|
|
@@ -11175,9 +13259,9 @@ ${issues}`);
|
|
|
11175
13259
|
return parsed.data;
|
|
11176
13260
|
}
|
|
11177
13261
|
async function writeJson(file, value) {
|
|
11178
|
-
const path2 =
|
|
11179
|
-
await
|
|
11180
|
-
await
|
|
13262
|
+
const path2 = resolve5(file);
|
|
13263
|
+
await mkdir10(dirname4(path2), { recursive: true });
|
|
13264
|
+
await writeFile12(path2, `${JSON.stringify(value, null, 2)}
|
|
11181
13265
|
`);
|
|
11182
13266
|
}
|
|
11183
13267
|
function size2(bytes) {
|
|
@@ -11185,7 +13269,7 @@ function size2(bytes) {
|
|
|
11185
13269
|
const n3 = bytes / 1024;
|
|
11186
13270
|
return n3 < 1024 ? `${n3 < 10 ? n3.toFixed(1) : Math.round(n3)} KB` : `${(n3 / 1024).toFixed(1)} MB`;
|
|
11187
13271
|
}
|
|
11188
|
-
function step(
|
|
11189
|
-
process.stderr.write(`${
|
|
13272
|
+
function step(message2) {
|
|
13273
|
+
process.stderr.write(`${message2}
|
|
11190
13274
|
`);
|
|
11191
13275
|
}
|