@jokerized/decksmith 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +794 -0
  2. package/dist/cli.js +8906 -0
  3. package/dist/deck-runtime.js +55 -0
  4. package/dist/index.js +8590 -0
  5. package/dist/mcp.js +7809 -0
  6. package/dist/server/errors.js +73 -0
  7. package/dist/server/http.js +504 -0
  8. package/dist/server/main.js +91 -0
  9. package/dist/server/options.js +198 -0
  10. package/dist/server/pipeline.js +356 -0
  11. package/dist/server/queue.js +195 -0
  12. package/dist/server/ui.js +1614 -0
  13. package/dist/server/upload.js +232 -0
  14. package/dist/types/cli.d.ts +1 -0
  15. package/dist/types/deck/runtime.d.ts +59 -0
  16. package/dist/types/deck/subtitles.d.ts +101 -0
  17. package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
  18. package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
  19. package/dist/types/emit/archetypes/callout.d.ts +7 -0
  20. package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
  21. package/dist/types/emit/archetypes/data-table.d.ts +21 -0
  22. package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
  23. package/dist/types/emit/archetypes/grid.d.ts +16 -0
  24. package/dist/types/emit/archetypes/index.d.ts +19 -0
  25. package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
  26. package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
  27. package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
  28. package/dist/types/emit/archetypes/stack.d.ts +93 -0
  29. package/dist/types/emit/archetypes/title.d.ts +188 -0
  30. package/dist/types/emit/camera.d.ts +397 -0
  31. package/dist/types/emit/composition.d.ts +191 -0
  32. package/dist/types/emit/island.d.ts +19 -0
  33. package/dist/types/emit/kit.d.ts +256 -0
  34. package/dist/types/emit/svg.d.ts +177 -0
  35. package/dist/types/emit/theme.d.ts +65 -0
  36. package/dist/types/emit/themes/index.d.ts +40 -0
  37. package/dist/types/emit/themes/ink.d.ts +12 -0
  38. package/dist/types/emit/themes/mono.d.ts +20 -0
  39. package/dist/types/emit/themes/paper.d.ts +18 -0
  40. package/dist/types/index.d.ts +200 -0
  41. package/dist/types/mcp/main.d.ts +2 -0
  42. package/dist/types/mcp/prereqs.d.ts +22 -0
  43. package/dist/types/mcp/tools.d.ts +212 -0
  44. package/dist/types/narrate/narrate.d.ts +87 -0
  45. package/dist/types/narrate/tts.d.ts +134 -0
  46. package/dist/types/narrate/voices.d.ts +29 -0
  47. package/dist/types/pack/media.d.ts +61 -0
  48. package/dist/types/pack/pack.d.ts +16 -0
  49. package/dist/types/plan/codex.d.ts +24 -0
  50. package/dist/types/plan/duration.d.ts +394 -0
  51. package/dist/types/plan/prompt.d.ts +47 -0
  52. package/dist/types/plan/refs.d.ts +22 -0
  53. package/dist/types/plan/select.d.ts +116 -0
  54. package/dist/types/prefs.d.ts +43 -0
  55. package/dist/types/render/captions.d.ts +108 -0
  56. package/dist/types/render/ffmpeg.d.ts +129 -0
  57. package/dist/types/render/render.d.ts +123 -0
  58. package/dist/types/render/timing.d.ts +290 -0
  59. package/dist/types/server/errors.d.ts +11 -0
  60. package/dist/types/server/http.d.ts +57 -0
  61. package/dist/types/server/main.d.ts +1 -0
  62. package/dist/types/server/options.d.ts +90 -0
  63. package/dist/types/server/pipeline.d.ts +21 -0
  64. package/dist/types/server/queue.d.ts +105 -0
  65. package/dist/types/server/ui.d.ts +9 -0
  66. package/dist/types/server/upload.d.ts +107 -0
  67. package/dist/types/source/assets.d.ts +11 -0
  68. package/dist/types/source/fonts.d.ts +15 -0
  69. package/dist/types/source/markdown.d.ts +8 -0
  70. package/dist/types/types.d.ts +1992 -0
  71. package/dist/types/verify/budget.d.ts +41 -0
  72. package/dist/types/verify/check.d.ts +78 -0
  73. package/dist/types/verify/drift.d.ts +158 -0
  74. package/dist/types/verify/fidelity.d.ts +247 -0
  75. package/dist/types/verify/index.d.ts +207 -0
  76. package/dist/types/verify/overprint.d.ts +133 -0
  77. package/dist/types/verify/typefloor.d.ts +50 -0
  78. package/package.json +84 -0
@@ -0,0 +1,198 @@
1
+ import {
2
+ canvasProblem,
3
+ canvasWarnings,
4
+ FORMATS,
5
+ LEGIBLE_W,
6
+ MAX_ASPECT,
7
+ MAX_EDGE,
8
+ MIN_EDGE,
9
+ prefsSchema,
10
+ resizeFormat,
11
+ THEME_NAMES
12
+ } from "../index.js";
13
+ import { UploadError } from "./upload.js";
14
+ const MAX_PIXELS = 4e6;
15
+ const MAX_SIDE = Math.min(
16
+ MAX_EDGE,
17
+ 16 * Math.floor(Math.sqrt(MAX_PIXELS * MAX_ASPECT) / 16)
18
+ );
19
+ function formatCatalog() {
20
+ return Object.entries(FORMATS).map(([id, f]) => ({
21
+ id,
22
+ width: f.width,
23
+ height: f.height,
24
+ navigable: f.navigable,
25
+ // JSON has no Infinity; null reads as "no ceiling", which is what it means.
26
+ maxSeconds: f.maxSeconds === void 0 || !Number.isFinite(f.maxSeconds) ? null : f.maxSeconds
27
+ }));
28
+ }
29
+ function catalog() {
30
+ return {
31
+ formats: formatCatalog(),
32
+ themes: THEME_NAMES,
33
+ tones: ["plain", "academic", "conversational", "punchy"],
34
+ densities: ["sparse", "normal", "dense"],
35
+ /** How many of a beat's stops speak. A different axis from `densities`. */
36
+ narrationDensities: ["high", "medium", "low"],
37
+ slides: { min: 3, max: 40, default: prefsSchema.parse({}).slides },
38
+ /** `null` default: no target, which is what every deck did before this. */
39
+ duration: { min: 10, max: 1800, default: null },
40
+ speed: { min: 0.25, max: 3, default: 1 },
41
+ // The picker enforces exactly these, so a size the page offers is a size
42
+ // this server will build. They were three different tables until the UI's
43
+ // own maximum came back 400 from the server it posts to.
44
+ canvas: {
45
+ minSide: MIN_EDGE,
46
+ maxSide: MAX_SIDE,
47
+ maxPixels: MAX_PIXELS,
48
+ maxAspect: MAX_ASPECT,
49
+ /** Below this a deck lays out but is not legible. A warning, never a refusal. */
50
+ legibleWidth: LEGIBLE_W
51
+ },
52
+ defaults: { format: "deck-16x9", narrate: false, video: false }
53
+ };
54
+ }
55
+ function parseOptions(fields) {
56
+ const warnings = [];
57
+ const formatId = str(fields.format) ?? "deck-16x9";
58
+ const preset = FORMATS[formatId];
59
+ if (!preset) {
60
+ throw new UploadError(
61
+ `Unknown format "${formatId}".`,
62
+ `Choose one of: ${Object.keys(FORMATS).join(", ")}. GET /api/formats lists their sizes.`
63
+ );
64
+ }
65
+ const format = withCanvas(preset, fields, warnings);
66
+ const patch = {};
67
+ if (str(fields.theme) !== void 0) patch.theme = requireTheme(str(fields.theme));
68
+ if (str(fields.slides) !== void 0) patch.slides = int("slides", fields.slides);
69
+ if (str(fields.lang) !== void 0) patch.lang = requireLang(str(fields.lang));
70
+ if (str(fields.tone) !== void 0) patch.tone = str(fields.tone);
71
+ if (str(fields.density) !== void 0) patch.density = str(fields.density);
72
+ if (str(fields.duration) !== void 0)
73
+ patch.duration = num("duration", fields.duration);
74
+ if (str(fields.speed) !== void 0) patch.animationSpeed = num("speed", fields.speed);
75
+ const narrateWanted = bool(fields.narrate) ?? false;
76
+ const video = bool(fields.video) ?? false;
77
+ const narrate = narrateWanted || video;
78
+ if (video && !narrateWanted)
79
+ warnings.push("video needs narration to time itself, so narration was turned on");
80
+ const narration = { enabled: narrate };
81
+ if (str(fields.voice) !== void 0) narration.voice = requireVoice(str(fields.voice));
82
+ if (str(fields.narrationDensity) !== void 0) narration.density = str(fields.narrationDensity);
83
+ patch.narration = narration;
84
+ const parsed = prefsSchema.safeParse(patch);
85
+ if (!parsed.success) {
86
+ const issue = parsed.error.issues[0];
87
+ const where = issue?.path.join(".") || "options";
88
+ throw new UploadError(
89
+ `${where}: ${issue?.message ?? "is not a valid option"}.`,
90
+ "GET /api/formats lists every option and the range it accepts."
91
+ );
92
+ }
93
+ return {
94
+ format,
95
+ formatId,
96
+ prefs: parsed.data,
97
+ narrate,
98
+ video,
99
+ stated: { theme: patch.theme !== void 0, lang: patch.lang !== void 0 },
100
+ warnings
101
+ };
102
+ }
103
+ function withCanvas(preset, fields, warnings) {
104
+ const hasW = str(fields.width) !== void 0;
105
+ const hasH = str(fields.height) !== void 0;
106
+ if (!hasW && !hasH) return preset;
107
+ if (hasW !== hasH) {
108
+ throw new UploadError(
109
+ "width and height must be given together.",
110
+ `Send both, or neither and let the "${preset.id}" preset decide.`
111
+ );
112
+ }
113
+ const width = even("width", fields.width, warnings);
114
+ const height = even("height", fields.height, warnings);
115
+ const problem = canvasProblem(width, height);
116
+ if (problem) {
117
+ throw new UploadError(
118
+ problem.replace(/--/g, ""),
119
+ `Canvases run ${MIN_EDGE}\u2013${MAX_SIDE}px a side, up to ${MAX_PIXELS / 1e6} megapixels, between 1:${MAX_ASPECT} and ${MAX_ASPECT}:1. GET /api/formats states all of it.`
120
+ );
121
+ }
122
+ if (width * height > MAX_PIXELS) {
123
+ throw new UploadError(
124
+ `${width}\xD7${height} is ${(width * height / 1e6).toFixed(1)} megapixels, over this server's ${MAX_PIXELS / 1e6} megapixel ceiling.`,
125
+ "Pick a smaller canvas; capture holds whole frames in memory and this box is shared."
126
+ );
127
+ }
128
+ warnings.push(...canvasWarnings(width, height));
129
+ return resizeFormat(preset, width, height);
130
+ }
131
+ function even(name, raw, warnings) {
132
+ const n = int(name, raw);
133
+ if (n % 2 === 0 || !Number.isFinite(n)) return n;
134
+ warnings.push(`${name} ${n} rounded down to ${n - 1}; h264 needs even dimensions`);
135
+ return n - 1;
136
+ }
137
+ function requireTheme(value) {
138
+ if (!THEME_NAMES.includes(value)) {
139
+ throw new UploadError(`Unknown theme "${value}".`, `Choose one of: ${THEME_NAMES.join(", ")}.`);
140
+ }
141
+ return value;
142
+ }
143
+ function requireLang(value) {
144
+ if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/.test(value)) {
145
+ throw new UploadError(
146
+ `"${value}" is not a language tag.`,
147
+ 'Use a BCP-47 tag such as "en", "ko" or "en-GB".'
148
+ );
149
+ }
150
+ return value;
151
+ }
152
+ function requireVoice(value) {
153
+ if (!/^[A-Za-z]{2,3}-[A-Za-z0-9-]{2,40}Neural$/.test(value)) {
154
+ throw new UploadError(
155
+ `"${value}" is not an edge-tts voice id.`,
156
+ 'Voice ids look like "en-US-AvaMultilingualNeural". Leave it unset to get one chosen for the language and tone.'
157
+ );
158
+ }
159
+ return value;
160
+ }
161
+ function str(value) {
162
+ const trimmed = value?.trim();
163
+ return trimmed ? trimmed : void 0;
164
+ }
165
+ function bool(value) {
166
+ const v = str(value)?.toLowerCase();
167
+ if (v === void 0) return void 0;
168
+ if (["1", "true", "on", "yes"].includes(v)) return true;
169
+ if (["0", "false", "off", "no"].includes(v)) return false;
170
+ throw new UploadError(`"${value}" is not a yes or no.`, "Send true or false.");
171
+ }
172
+ function num(name, raw) {
173
+ const n = Number(raw);
174
+ if (!Number.isFinite(n)) {
175
+ throw new UploadError(
176
+ `${name} expects a number, got "${raw}".`,
177
+ "Send a plain decimal number."
178
+ );
179
+ }
180
+ return n;
181
+ }
182
+ function int(name, raw) {
183
+ const n = num(name, raw);
184
+ if (!Number.isInteger(n)) {
185
+ throw new UploadError(
186
+ `${name} expects a whole number, got "${raw}".`,
187
+ "Drop the decimal point."
188
+ );
189
+ }
190
+ return n;
191
+ }
192
+ export {
193
+ MAX_PIXELS,
194
+ MAX_SIDE,
195
+ catalog,
196
+ formatCatalog,
197
+ parseOptions
198
+ };
@@ -0,0 +1,356 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import {
4
+ assertRefsResolve,
5
+ buildDeck,
6
+ codexPlanner,
7
+ durationPlan,
8
+ fetchFigures,
9
+ narrate,
10
+ PACK_VERSION,
11
+ parseMarkdown,
12
+ planMedia,
13
+ render,
14
+ writePack
15
+ } from "../index.js";
16
+ import {
17
+ insideRoot,
18
+ looksLikeZip,
19
+ MARKDOWN_EXTS,
20
+ pickMarkdown,
21
+ readZip,
22
+ UploadError
23
+ } from "./upload.js";
24
+ const AUDIO_DIR = "audio";
25
+ const NARRATION_FILE = "narration.json";
26
+ const MAX_REMOTE_FIGURES = 40;
27
+ const FEED_FORMATS = /* @__PURE__ */ new Set(["short-9x16", "post-1x1"]);
28
+ function stagesFor(options) {
29
+ const stages = ["ingest", "plan"];
30
+ if (options.narrate) stages.push("narrate");
31
+ stages.push("build");
32
+ if (options.video) stages.push("render");
33
+ return stages;
34
+ }
35
+ async function runPipeline(job, input) {
36
+ const { options } = input;
37
+ const dirs = {
38
+ upload: join(job.dir, "upload"),
39
+ src: join(job.dir, "src"),
40
+ audio: join(job.dir, AUDIO_DIR),
41
+ deck: join(job.dir, "deck")
42
+ };
43
+ const warnings = [...options.warnings];
44
+ const url = (rel) => `/d/${job.id}/${rel}`;
45
+ job.begin("ingest");
46
+ const source = await ingest(job, input, dirs, warnings);
47
+ const dropped = warnings.filter((w) => w.startsWith("figure ")).length;
48
+ job.done(
49
+ "ingest",
50
+ `${source.sections.length} sections, ${source.figures.length} figures${dropped > 0 ? ` (${dropped} left out)` : ""}`
51
+ );
52
+ job.begin("plan");
53
+ job.log("plan: asking Codex for a storyboard \u2014 this is the slow one, about a minute");
54
+ const prefs = options.stated.lang ? options.prefs : { ...options.prefs, lang: source.lang };
55
+ const planned = await codexPlanner(source, { prefs });
56
+ const storyboard = {
57
+ ...planned,
58
+ lang: prefs.lang,
59
+ theme: options.stated.theme ? prefs.theme : planned.theme
60
+ };
61
+ assertRefsResolve(storyboard, source);
62
+ await writeJson(join(job.dir, "storyboard.json"), storyboard);
63
+ job.done("plan", `${storyboard.beats.length} beats`);
64
+ let narration;
65
+ if (options.narrate) {
66
+ job.begin("narrate");
67
+ const speaking = storyboard.beats.filter((b) => b.narration?.trim()).length;
68
+ if (speaking === 0) {
69
+ job.skip("narrate", "no beat came back with narration text");
70
+ warnings.push("the plan had nothing to say aloud, so the deck is silent");
71
+ } else {
72
+ job.log(`narrate: ${speaking} of ${storyboard.beats.length} beats speak`);
73
+ await mkdir(dirs.audio, { recursive: true });
74
+ const spoken = await narrate(storyboard, source, prefs, {
75
+ dir: dirs.audio,
76
+ format: options.format
77
+ });
78
+ await writeJson(join(dirs.audio, NARRATION_FILE), spoken);
79
+ narration = { voice: spoken.voice, dir: AUDIO_DIR, beats: spoken.beats };
80
+ const segments = Object.values(spoken.beats).flat();
81
+ const seconds = segments.reduce((sum, s) => sum + s.seconds, 0);
82
+ job.done("narrate", `${segments.length} segments, ${seconds.toFixed(1)}s in ${spoken.voice}`);
83
+ }
84
+ }
85
+ job.begin("build");
86
+ const paced = durationPlan(prefs);
87
+ for (const w of paced.warnings) warnings.push(w);
88
+ const built = await buildDeck(storyboard, source, dirs.deck, {
89
+ format: options.format,
90
+ theme: options.stated.theme ? prefs.theme : storyboard.theme,
91
+ // The DURATION target owns the pace when there is one, and is `animationSpeed`
92
+ // itself when there is not. Its warnings are the job's: a target that cannot
93
+ // be met is something the person who asked for it must be told.
94
+ speed: paced.speed,
95
+ assetsFrom: dirs.src,
96
+ ...narration ? { narration, audioFrom: dirs.audio } : {},
97
+ onStep: (line) => job.log(line),
98
+ // A beat the planner over-filled costs that slide, not the deck. Named in
99
+ // the warnings so the result says what is missing rather than quietly
100
+ // showing eleven slides where twelve were planned.
101
+ onBeatError: (id, err) => {
102
+ warnings.push(`slide ${id} was left out: ${err.message}`);
103
+ job.log(`build: dropped ${id} \u2014 ${err.message}`);
104
+ }
105
+ });
106
+ for (const d of built.cut.dropped) warnings.push(`cut ${d.beat.id} \u2014 ${d.reason}`);
107
+ for (const d of built.cut.dangling) warnings.push(`check the wording \u2014 ${d.reason}`);
108
+ const packUrl = await pack(job, { storyboard, source, prefs, narration, dirs, url });
109
+ job.done(
110
+ "build",
111
+ `${built.cut.kept.length} beats at ${options.format.width}\xD7${options.format.height}`
112
+ );
113
+ let videoUrl;
114
+ let srtUrl;
115
+ let duration = built.cut.seconds;
116
+ if (options.video) {
117
+ job.begin("render");
118
+ if (!narration) {
119
+ job.skip("render", "there is no narration to time the video against");
120
+ warnings.push("no video: a render needs narration for its timing manifest");
121
+ } else {
122
+ job.log("render: capturing frames \u2014 two minutes for a four-minute video");
123
+ const out = await render({
124
+ deck: dirs.deck,
125
+ out: join(dirs.deck, "video.mp4"),
126
+ subtitles: "sidecar",
127
+ log: (line) => job.log(line),
128
+ // The last of the three length levers, and the only one that acts on a
129
+ // finished file. Plan time already spent the budget on what gets said;
130
+ // this closes whatever gap survived it, and says what it cost.
131
+ ...prefs.duration ? { targetSeconds: prefs.duration } : {}
132
+ });
133
+ videoUrl = url("video.mp4");
134
+ if (out.srt) srtUrl = url(relative(dirs.deck, out.srt).split("\\").join("/"));
135
+ duration = out.seconds;
136
+ if (out.playback > 1) {
137
+ warnings.push(
138
+ `playback was sped up ${out.playback}\xD7 to reach the ${prefs.duration}s target; the audio is time-stretched, not re-spoken`
139
+ );
140
+ }
141
+ if (!out.burned && out.srt && FEED_FORMATS.has(options.formatId)) {
142
+ warnings.push(
143
+ `captions are a separate ${relative(dirs.deck, out.srt)} \u2014 feed platforms discard an uploaded .srt, so for a short run \`decksmith render --subtitles burn\` to put them in the picture`
144
+ );
145
+ }
146
+ job.done("render", `${out.frames} frames, ${out.seconds.toFixed(1)}s`);
147
+ }
148
+ }
149
+ if (!built.navigable) {
150
+ warnings.push(
151
+ `${options.formatId} renders linearly, so the deck opens as index.html with no navigation`
152
+ );
153
+ }
154
+ return {
155
+ deckUrl: url(built.navigable ? "deck.html" : "index.html"),
156
+ ...videoUrl ? { videoUrl } : {},
157
+ ...srtUrl ? { srtUrl } : {},
158
+ ...packUrl ? { packUrl } : {},
159
+ slides: built.cut.kept.length,
160
+ duration: Math.round(duration * 1e3) / 1e3,
161
+ warnings
162
+ };
163
+ }
164
+ async function ingest(job, input, dirs, warnings) {
165
+ await mkdir(dirs.upload, { recursive: true });
166
+ const root = resolve(dirs.upload);
167
+ let docPath;
168
+ if (looksLikeZip(input.upload.bytes)) {
169
+ const { files, warnings: zipWarnings } = readZip(input.upload.bytes);
170
+ warnings.push(...zipWarnings);
171
+ for (const [rel, bytes] of Object.entries(files)) {
172
+ const to = resolve(join(root, rel));
173
+ if (!insideRoot(root, to))
174
+ throw new UploadError(`Refusing to write ${rel}.`, "Re-zip from inside the folder.");
175
+ await mkdir(dirname(to), { recursive: true });
176
+ await writeFile(to, bytes);
177
+ }
178
+ docPath = join(root, pickMarkdown(files));
179
+ job.log(
180
+ `ingest: unpacked ${Object.keys(files).length} file(s), reading ${pickMarkdown(files)}`
181
+ );
182
+ } else {
183
+ const ext = (input.upload.filename.match(/\.[^.\\/]+$/)?.[0] ?? "").toLowerCase();
184
+ if (ext && !MARKDOWN_EXTS.includes(ext)) {
185
+ throw new UploadError(
186
+ `"${input.upload.filename}" is a ${ext} file.`,
187
+ `DeckSmith reads ${MARKDOWN_EXTS.join(", ")} or a .zip containing one. Export the document to markdown first.`
188
+ );
189
+ }
190
+ docPath = join(root, "document.md");
191
+ await writeFile(docPath, input.upload.bytes);
192
+ }
193
+ const text = await readFile(docPath, "utf8");
194
+ const parsed = parseMarkdown(text, {
195
+ lang: input.options.stated.lang ? input.options.prefs.lang : void 0
196
+ });
197
+ if (parsed.sections.length === 0) {
198
+ throw new UploadError(
199
+ "The document has no readable content.",
200
+ "It parsed to nothing \u2014 check that you uploaded the markdown and not an empty or binary file."
201
+ );
202
+ }
203
+ if (parsed.sections.every((s) => s.heading.trim() === "")) {
204
+ throw new UploadError(
205
+ "The document has no headings, so there is nothing to structure a deck around.",
206
+ "Add at least one `# Heading`. DeckSmith plans one beat per idea and headings are how it finds them."
207
+ );
208
+ }
209
+ job.log(
210
+ `ingest: ${parsed.sections.length} sections, ${parsed.figures.length} figures, ${parsed.equations.length} equations, ${parsed.tables.length} tables`
211
+ );
212
+ const guarded = await guardFigures(parsed, root, input.fetchRemoteFigures, warnings);
213
+ const source = await fetchFigures(guarded, join(resolve(dirs.src), "assets"));
214
+ await writeJson(join(dirs.src, "source.json"), source);
215
+ return source;
216
+ }
217
+ const BLOCKED = [
218
+ /^10\./,
219
+ /^127\./,
220
+ /^169\.254\./,
221
+ /^172\.(1[6-9]|2\d|3[01])\./,
222
+ /^192\.168\./,
223
+ /^0\./
224
+ ];
225
+ async function reachable(url) {
226
+ let host2;
227
+ try {
228
+ host2 = new URL(url).hostname;
229
+ } catch {
230
+ return "is not a URL this server can parse";
231
+ }
232
+ const bare = host2.replace(/^\[|\]$/g, "");
233
+ const addrs = [];
234
+ try {
235
+ const { lookup } = await import("node:dns/promises");
236
+ for (const a of await lookup(bare, { all: true })) addrs.push(a.address);
237
+ } catch {
238
+ return "does not resolve";
239
+ }
240
+ for (const addr of addrs) {
241
+ if (BLOCKED.some((re) => re.test(addr))) return `resolves to a private address (${addr})`;
242
+ const v6 = addr.toLowerCase();
243
+ if (v6 === "::1" || v6.startsWith("fe80:") || v6.startsWith("fc") || v6.startsWith("fd")) {
244
+ return `resolves to a private address (${addr})`;
245
+ }
246
+ }
247
+ return addrs.length === 0 ? "does not resolve" : null;
248
+ }
249
+ async function guardFigures(source, root, allowRemote, warnings) {
250
+ const figures = [];
251
+ let remote = 0;
252
+ for (const figure of source.figures) {
253
+ const src = figure.src.trim();
254
+ const scheme = src.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
255
+ if (scheme === "http" || scheme === "https") {
256
+ if (!allowRemote) {
257
+ warnings.push(
258
+ `figure ${figure.id} was left out: it is hosted at ${host(src)} and this server does not fetch remote figures`
259
+ );
260
+ continue;
261
+ }
262
+ if (++remote > MAX_REMOTE_FIGURES) {
263
+ warnings.push(
264
+ `figure ${figure.id} was left out: more than ${MAX_REMOTE_FIGURES} remote figures`
265
+ );
266
+ continue;
267
+ }
268
+ const why = await reachable(src);
269
+ if (why) {
270
+ warnings.push(`figure ${figure.id} was left out: ${host(src)} ${why}`);
271
+ continue;
272
+ }
273
+ figures.push(figure);
274
+ continue;
275
+ }
276
+ if (scheme) {
277
+ warnings.push(`figure ${figure.id} was left out: "${scheme}:" figures are not read`);
278
+ continue;
279
+ }
280
+ const abs = resolve(join(root, src.replace(/^\.?\//, "")));
281
+ if (!insideRoot(root, abs)) {
282
+ warnings.push(`figure ${figure.id} was left out: "${src}" points outside the upload`);
283
+ continue;
284
+ }
285
+ if (!(await stat(abs).catch(() => null))?.isFile()) {
286
+ warnings.push(`figure ${figure.id} was left out: "${src}" is not in the upload`);
287
+ continue;
288
+ }
289
+ figures.push({ ...figure, src: abs });
290
+ }
291
+ return { ...source, figures };
292
+ }
293
+ async function pack(job, ctx) {
294
+ try {
295
+ const assets = join(resolve(ctx.dirs.src), "assets");
296
+ const requests = ctx.source.figures.map((f) => ({
297
+ id: f.id,
298
+ url: join(assets, f.src),
299
+ prefer: "bake"
300
+ }));
301
+ const plan = await planMedia(requests, async (url) => ({
302
+ bytes: new Uint8Array(await readFile(url))
303
+ }));
304
+ const files = { ...plan.files };
305
+ if (ctx.narration) {
306
+ for (const name of audioNames(ctx.narration)) {
307
+ files[`${AUDIO_DIR}/${name}`] = new Uint8Array(await readFile(join(ctx.dirs.audio, name)));
308
+ }
309
+ }
310
+ const container = {
311
+ version: PACK_VERSION,
312
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
313
+ title: ctx.storyboard.title,
314
+ prefs: {
315
+ ...ctx.prefs,
316
+ narration: { ...ctx.prefs.narration, enabled: ctx.narration !== void 0 }
317
+ },
318
+ source: ctx.source,
319
+ storyboard: ctx.storyboard,
320
+ ...ctx.narration ? { narration: await readJson(join(ctx.dirs.audio, NARRATION_FILE)) } : {},
321
+ media: plan.media
322
+ };
323
+ const bytes = await writePack(container, files, join(ctx.dirs.deck, "deck.deck"));
324
+ job.log(`pack: ${Math.round(bytes / 1024)} KB \u2192 deck.deck`);
325
+ return ctx.url("deck.deck");
326
+ } catch (err) {
327
+ job.log(`pack: skipped \u2014 ${err instanceof Error ? err.message : String(err)}`);
328
+ return void 0;
329
+ }
330
+ }
331
+ function audioNames(narration) {
332
+ return [
333
+ ...new Set(
334
+ Object.values(narration.beats).flat().map((s) => s.audio)
335
+ )
336
+ ].sort();
337
+ }
338
+ function host(src) {
339
+ try {
340
+ return new URL(src).host;
341
+ } catch {
342
+ return "another host";
343
+ }
344
+ }
345
+ async function writeJson(path, value) {
346
+ await mkdir(dirname(path), { recursive: true });
347
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
348
+ `);
349
+ }
350
+ async function readJson(path) {
351
+ return JSON.parse(await readFile(path, "utf8"));
352
+ }
353
+ export {
354
+ runPipeline,
355
+ stagesFor
356
+ };