@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.
- package/README.md +794 -0
- package/dist/cli.js +8906 -0
- package/dist/deck-runtime.js +55 -0
- package/dist/index.js +8590 -0
- package/dist/mcp.js +7809 -0
- package/dist/server/errors.js +73 -0
- package/dist/server/http.js +504 -0
- package/dist/server/main.js +91 -0
- package/dist/server/options.js +198 -0
- package/dist/server/pipeline.js +356 -0
- package/dist/server/queue.js +195 -0
- package/dist/server/ui.js +1614 -0
- package/dist/server/upload.js +232 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/deck/runtime.d.ts +59 -0
- package/dist/types/deck/subtitles.d.ts +101 -0
- package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
- package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
- package/dist/types/emit/archetypes/callout.d.ts +7 -0
- package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
- package/dist/types/emit/archetypes/data-table.d.ts +21 -0
- package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
- package/dist/types/emit/archetypes/grid.d.ts +16 -0
- package/dist/types/emit/archetypes/index.d.ts +19 -0
- package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
- package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
- package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
- package/dist/types/emit/archetypes/stack.d.ts +93 -0
- package/dist/types/emit/archetypes/title.d.ts +188 -0
- package/dist/types/emit/camera.d.ts +397 -0
- package/dist/types/emit/composition.d.ts +191 -0
- package/dist/types/emit/island.d.ts +19 -0
- package/dist/types/emit/kit.d.ts +256 -0
- package/dist/types/emit/svg.d.ts +177 -0
- package/dist/types/emit/theme.d.ts +65 -0
- package/dist/types/emit/themes/index.d.ts +40 -0
- package/dist/types/emit/themes/ink.d.ts +12 -0
- package/dist/types/emit/themes/mono.d.ts +20 -0
- package/dist/types/emit/themes/paper.d.ts +18 -0
- package/dist/types/index.d.ts +200 -0
- package/dist/types/mcp/main.d.ts +2 -0
- package/dist/types/mcp/prereqs.d.ts +22 -0
- package/dist/types/mcp/tools.d.ts +212 -0
- package/dist/types/narrate/narrate.d.ts +87 -0
- package/dist/types/narrate/tts.d.ts +134 -0
- package/dist/types/narrate/voices.d.ts +29 -0
- package/dist/types/pack/media.d.ts +61 -0
- package/dist/types/pack/pack.d.ts +16 -0
- package/dist/types/plan/codex.d.ts +24 -0
- package/dist/types/plan/duration.d.ts +394 -0
- package/dist/types/plan/prompt.d.ts +47 -0
- package/dist/types/plan/refs.d.ts +22 -0
- package/dist/types/plan/select.d.ts +116 -0
- package/dist/types/prefs.d.ts +43 -0
- package/dist/types/render/captions.d.ts +108 -0
- package/dist/types/render/ffmpeg.d.ts +129 -0
- package/dist/types/render/render.d.ts +123 -0
- package/dist/types/render/timing.d.ts +290 -0
- package/dist/types/server/errors.d.ts +11 -0
- package/dist/types/server/http.d.ts +57 -0
- package/dist/types/server/main.d.ts +1 -0
- package/dist/types/server/options.d.ts +90 -0
- package/dist/types/server/pipeline.d.ts +21 -0
- package/dist/types/server/queue.d.ts +105 -0
- package/dist/types/server/ui.d.ts +9 -0
- package/dist/types/server/upload.d.ts +107 -0
- package/dist/types/source/assets.d.ts +11 -0
- package/dist/types/source/fonts.d.ts +15 -0
- package/dist/types/source/markdown.d.ts +8 -0
- package/dist/types/types.d.ts +1992 -0
- package/dist/types/verify/budget.d.ts +41 -0
- package/dist/types/verify/check.d.ts +78 -0
- package/dist/types/verify/drift.d.ts +158 -0
- package/dist/types/verify/fidelity.d.ts +247 -0
- package/dist/types/verify/index.d.ts +207 -0
- package/dist/types/verify/overprint.d.ts +133 -0
- package/dist/types/verify/typefloor.d.ts +50 -0
- package/package.json +84 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { UploadError } from "./upload.js";
|
|
2
|
+
const HINTS = [
|
|
3
|
+
{
|
|
4
|
+
when: /"codex" CLI is not on PATH/i,
|
|
5
|
+
hint: "Install it on the server with `npm i -g @openai/codex`, then run `codex login` as the user this process runs as. Planning is the only stage that needs it."
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
when: /Codex did not finish within/i,
|
|
9
|
+
hint: "The document is probably long. Split it, or ask for fewer slides, and try again."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
when: /codex exec exited|final message was not JSON|produced no final message/i,
|
|
13
|
+
hint: "Codex answered but not with a storyboard. Retry once \u2014 if it repeats, the document is likely too long or too unstructured to plan from."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
when: /edge-tts is not installed/i,
|
|
17
|
+
hint: "Install it on the server with `python3 -m pip install --user edge-tts`, or submit with narration off \u2014 the deck itself needs no network."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
when: /\bffprobe\b|\bffmpeg\b/i,
|
|
21
|
+
hint: "Install ffmpeg on the server (`brew install ffmpeg` / `apt install ffmpeg`). Narration measures its own audio with ffprobe and the video is muxed with ffmpeg."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
when: /Cannot burn in captions/i,
|
|
25
|
+
hint: "Ask for sidecar subtitles instead of burned-in ones, or install a Chrome for the caption pass."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
when: /browsers install chrome|DECKSMITH_CHROME|No (installed )?(Chrome|browser)/i,
|
|
29
|
+
hint: "Install a browser on the server with `npx puppeteer browsers install chrome`, or point DECKSMITH_CHROME at one. Only the video render needs it; the deck does not."
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
when: /timing\.json is missing/i,
|
|
33
|
+
hint: "The video needs narration to time itself against. Re-submit with narration on."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
when: /Deck runtime missing/i,
|
|
37
|
+
hint: "The server was started without a build. Run `npm run build` and restart it."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
// Seen on a real run: "Codex returned a storyboard that does not validate:
|
|
41
|
+
// beats.3.params.bars: Too small". The planner is schema-constrained but not
|
|
42
|
+
// schema-guaranteed, and a retry usually lands — so the hint has to match the
|
|
43
|
+
// sentence the library actually writes, not the one this table first guessed.
|
|
44
|
+
when: /storyboard that does not validate|is not a valid storyboard|does not resolve|dangling/i,
|
|
45
|
+
hint: "The planner answered with a storyboard that does not fit the schema \u2014 a beat came back half-filled. This is usually one bad roll of the dice; submit the same document again."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// A planner problem that surfaces wherever the emitter is first called —
|
|
49
|
+
// which is `narrate`, because narration asks the emitter how many stops a
|
|
50
|
+
// beat has. The stage in the UI is therefore honest about WHERE it stopped
|
|
51
|
+
// and misleading about WHAT went wrong, so the hint has to say so.
|
|
52
|
+
when: /equation-walk .*term|none of its \d+ term/i,
|
|
53
|
+
hint: "The planner labelled a symbol the equation does not contain. Nothing is wrong with your document; it is a planning miss and a fresh run usually writes the term correctly. Reported at the narration stage because that is the first stage that builds a slide."
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
when: /unrecognised image header/i,
|
|
57
|
+
hint: "One of the figures is not a PNG, JPEG or GIF. SVG and PDF figures are not read; export them to PNG first."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
when: /ENOSPC|no space left/i,
|
|
61
|
+
hint: "The server is out of disk. Nothing you can do from here \u2014 tell whoever runs it."
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
const FALLBACK = "This one is not a document problem we recognise. The stage list above shows how far it got; re-running an identical upload is worth one try.";
|
|
65
|
+
function explain(err) {
|
|
66
|
+
if (err instanceof UploadError) return { message: err.message, hint: err.hint };
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
const clean = message.split("\n at ")[0]?.trim() || "The job failed.";
|
|
69
|
+
return { message: clean, hint: HINTS.find((h) => h.when.test(message))?.hint ?? FALLBACK };
|
|
70
|
+
}
|
|
71
|
+
export {
|
|
72
|
+
explain
|
|
73
|
+
};
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { catalog, parseOptions } from "./options.js";
|
|
7
|
+
import { runPipeline, stagesFor } from "./pipeline.js";
|
|
8
|
+
import { Queue, QueueFullError } from "./queue.js";
|
|
9
|
+
import { parseMultipart, readBody, UploadError } from "./upload.js";
|
|
10
|
+
const ID = /^[A-Za-z0-9_-]{22}$/;
|
|
11
|
+
const KEEP = "job.json";
|
|
12
|
+
const RAW = "upload.bin";
|
|
13
|
+
async function keepUpload(dir, upload) {
|
|
14
|
+
try {
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
const kept = { filename: upload.filename, fields: upload.fields, createdAt: Date.now() };
|
|
17
|
+
await writeFile(join(dir, KEEP), JSON.stringify(kept));
|
|
18
|
+
await writeFile(join(dir, RAW), upload.bytes);
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readKept(dir) {
|
|
23
|
+
try {
|
|
24
|
+
const kept = JSON.parse(await readFile(join(dir, KEEP), "utf8"));
|
|
25
|
+
if (typeof kept?.filename !== "string" || typeof kept?.fields !== "object") return null;
|
|
26
|
+
return { kept, bytes: await readFile(join(dir, RAW)) };
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function createDeckServer(opts) {
|
|
32
|
+
const queue = new Queue({
|
|
33
|
+
maxQueued: opts.maxQueued,
|
|
34
|
+
ttlMs: opts.ttlMs,
|
|
35
|
+
onExpire: (id, dir) => {
|
|
36
|
+
opts.log(`sweep: ${id} expired`);
|
|
37
|
+
opts.removeDir(dir);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
const requests = new RateLimiter(opts.requestsPerMinute, 6e4);
|
|
41
|
+
const jobs = new RateLimiter(opts.jobsPerHour, 60 * 6e4);
|
|
42
|
+
const server = createServer((req, res) => {
|
|
43
|
+
handle(req, res).catch((err) => fail(res, err));
|
|
44
|
+
});
|
|
45
|
+
server.requestTimeout = 12e4;
|
|
46
|
+
server.headersTimeout = 3e4;
|
|
47
|
+
async function handle(req, res) {
|
|
48
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
49
|
+
const path = url.pathname;
|
|
50
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
51
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
52
|
+
if (!requests.take(ipOf(req))) {
|
|
53
|
+
return send(res, 429, {
|
|
54
|
+
error: { message: "Too many requests.", hint: "Slow down and retry in a minute." }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (req.method === "GET" && (path === "/" || path === "/index.html")) {
|
|
58
|
+
const page = await uiPage(opts.log);
|
|
59
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
60
|
+
res.end(page);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (req.method === "GET" && path === "/api/formats") {
|
|
64
|
+
return send(res, 200, catalog());
|
|
65
|
+
}
|
|
66
|
+
if (req.method === "POST" && path === "/api/jobs") {
|
|
67
|
+
return submit(req, res);
|
|
68
|
+
}
|
|
69
|
+
const events = path.match(/^\/api\/jobs\/([^/]+)\/events$/);
|
|
70
|
+
if (req.method === "GET" && events) return stream(req, res, events[1]);
|
|
71
|
+
const retry = path.match(/^\/api\/jobs\/([^/]+)\/retry$/);
|
|
72
|
+
if (req.method === "POST" && retry) return again(req, res, retry[1]);
|
|
73
|
+
const one = path.match(/^\/api\/jobs\/([^/]+)$/);
|
|
74
|
+
if (req.method === "GET" && one) {
|
|
75
|
+
const id = one[1];
|
|
76
|
+
const view = ID.test(id) ? queue.view(id) : void 0;
|
|
77
|
+
if (view) return send(res, 200, view);
|
|
78
|
+
const kept = ID.test(id) ? await readKept(join(resolve(opts.work), id)) : null;
|
|
79
|
+
if (kept) {
|
|
80
|
+
return send(res, 200, {
|
|
81
|
+
id,
|
|
82
|
+
state: "interrupted",
|
|
83
|
+
filename: kept.kept.filename,
|
|
84
|
+
createdAt: kept.kept.createdAt
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return send(res, 404, {
|
|
88
|
+
error: {
|
|
89
|
+
message: "No such job.",
|
|
90
|
+
hint: "It may have expired; jobs are kept for a couple of hours."
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const deck = path.match(/^\/d\/([^/]+)(\/.*)?$/);
|
|
95
|
+
if ((req.method === "GET" || req.method === "HEAD") && deck) {
|
|
96
|
+
return serveDeck(req, res, deck[1], deck[2] ?? "/");
|
|
97
|
+
}
|
|
98
|
+
send(res, 404, {
|
|
99
|
+
error: {
|
|
100
|
+
message: `No route for ${req.method} ${path}.`,
|
|
101
|
+
hint: "The API is POST /api/jobs, GET /api/jobs/:id and GET /api/formats."
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function submit(req, res) {
|
|
106
|
+
const body = await readBody(req, opts.maxUploadBytes);
|
|
107
|
+
const upload = await parseMultipart(body, req.headers["content-type"] ?? "");
|
|
108
|
+
const options = parseOptions(upload.fields);
|
|
109
|
+
if (!jobs.take(ipOf(req))) {
|
|
110
|
+
return send(res, 429, {
|
|
111
|
+
error: {
|
|
112
|
+
message: `That address has already submitted ${opts.jobsPerHour} decks this hour.`,
|
|
113
|
+
hint: "Each deck is minutes of CPU. Wait for the hour to roll over."
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const id = randomBytes(16).toString("base64url");
|
|
118
|
+
const dir = join(resolve(opts.work), id);
|
|
119
|
+
await keepUpload(dir, upload);
|
|
120
|
+
const view = queue.submit({
|
|
121
|
+
id,
|
|
122
|
+
dir,
|
|
123
|
+
stages: stagesFor(options),
|
|
124
|
+
run: (job) => (opts.run ?? runPipeline)(job, {
|
|
125
|
+
upload,
|
|
126
|
+
options,
|
|
127
|
+
fetchRemoteFigures: opts.fetchRemoteFigures
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
opts.log(
|
|
131
|
+
`job ${id}: ${upload.filename} (${Math.round(upload.bytes.length / 1024)} KB) \u2192 ${options.formatId}${options.narrate ? " +narration" : ""}${options.video ? " +video" : ""}, position ${view.queuePosition ?? 0}`
|
|
132
|
+
);
|
|
133
|
+
send(res, 202, { id, queuePosition: view.queuePosition ?? 0 });
|
|
134
|
+
}
|
|
135
|
+
async function again(req, res, old) {
|
|
136
|
+
if (!ID.test(old)) return send(res, 404, { error: { message: "No such job." } });
|
|
137
|
+
const kept = await readKept(join(resolve(opts.work), old));
|
|
138
|
+
if (!kept) {
|
|
139
|
+
return send(res, 404, {
|
|
140
|
+
error: {
|
|
141
|
+
message: "That upload is no longer on the server.",
|
|
142
|
+
hint: "Choose the file again."
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!jobs.take(ipOf(req))) {
|
|
147
|
+
return send(res, 429, {
|
|
148
|
+
error: {
|
|
149
|
+
message: `That address has already submitted ${opts.jobsPerHour} decks this hour.`,
|
|
150
|
+
hint: "Each deck is minutes of CPU. Wait for the hour to roll over."
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const upload = {
|
|
155
|
+
filename: kept.kept.filename,
|
|
156
|
+
bytes: kept.bytes,
|
|
157
|
+
fields: kept.kept.fields
|
|
158
|
+
};
|
|
159
|
+
const options = parseOptions(upload.fields);
|
|
160
|
+
const id = randomBytes(16).toString("base64url");
|
|
161
|
+
const dir = join(resolve(opts.work), id);
|
|
162
|
+
await keepUpload(dir, upload);
|
|
163
|
+
const view = queue.submit({
|
|
164
|
+
id,
|
|
165
|
+
dir,
|
|
166
|
+
stages: stagesFor(options),
|
|
167
|
+
run: (job) => (opts.run ?? runPipeline)(job, {
|
|
168
|
+
upload,
|
|
169
|
+
options,
|
|
170
|
+
fetchRemoteFigures: opts.fetchRemoteFigures
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
opts.log(`job ${id}: retry of ${old} \u2014 ${upload.filename} \u2192 ${options.formatId}`);
|
|
174
|
+
send(res, 202, { id, queuePosition: view.queuePosition ?? 0 });
|
|
175
|
+
}
|
|
176
|
+
function stream(req, res, id) {
|
|
177
|
+
if (!ID.test(id) || !queue.view(id)) {
|
|
178
|
+
send(res, 404, { error: { message: "No such job.", hint: "It may have expired." } });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
res.writeHead(200, {
|
|
182
|
+
"content-type": "text/event-stream",
|
|
183
|
+
"cache-control": "no-cache, no-transform",
|
|
184
|
+
connection: "keep-alive"
|
|
185
|
+
});
|
|
186
|
+
let last = "";
|
|
187
|
+
const push = (view) => {
|
|
188
|
+
const { ms: _ms, ...stable } = view;
|
|
189
|
+
const body = JSON.stringify(view);
|
|
190
|
+
if (JSON.stringify(stable) === last) return;
|
|
191
|
+
last = JSON.stringify(stable);
|
|
192
|
+
res.write(`data: ${body}
|
|
193
|
+
|
|
194
|
+
`);
|
|
195
|
+
if (view.state === "done" || view.state === "error") {
|
|
196
|
+
clearInterval(beat);
|
|
197
|
+
stop();
|
|
198
|
+
res.end();
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const stop = queue.watch(id, push);
|
|
202
|
+
const beat = setInterval(() => res.write(": keep-alive\n\n"), 2e4);
|
|
203
|
+
req.on("close", () => {
|
|
204
|
+
clearInterval(beat);
|
|
205
|
+
stop();
|
|
206
|
+
});
|
|
207
|
+
const now = queue.view(id);
|
|
208
|
+
if (now) push(now);
|
|
209
|
+
}
|
|
210
|
+
async function serveDeck(req, res, id, rest) {
|
|
211
|
+
if (!ID.test(id))
|
|
212
|
+
return send(res, 404, { error: { message: "No such deck.", hint: "Check the link." } });
|
|
213
|
+
const base = resolve(join(opts.work, id, "deck"));
|
|
214
|
+
const rel = safeUrlPath(rest);
|
|
215
|
+
if (rel === null) {
|
|
216
|
+
return send(res, 400, {
|
|
217
|
+
error: {
|
|
218
|
+
message: "That path is not one we will serve.",
|
|
219
|
+
hint: "Follow the link the job returned rather than editing it."
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
const file = resolve(join(base, rel || "deck.html"));
|
|
224
|
+
if (file !== base && !file.startsWith(base + sep)) {
|
|
225
|
+
return send(res, 403, {
|
|
226
|
+
error: {
|
|
227
|
+
message: "That path is outside the deck.",
|
|
228
|
+
hint: "Follow the link the job returned rather than editing it."
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const info = await stat(file).catch(() => null);
|
|
233
|
+
if (!info?.isFile()) {
|
|
234
|
+
return send(res, 404, {
|
|
235
|
+
error: {
|
|
236
|
+
message: "Not in this deck.",
|
|
237
|
+
hint: "The job may still be running, or its files may have expired."
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const ext = extname(file).toLowerCase();
|
|
242
|
+
const type = MIME[ext] ?? "application/octet-stream";
|
|
243
|
+
const headers = {
|
|
244
|
+
"content-type": type,
|
|
245
|
+
"accept-ranges": "bytes",
|
|
246
|
+
// Each job id is fresh, so its files never change under the same URL.
|
|
247
|
+
"cache-control": "private, max-age=600"
|
|
248
|
+
};
|
|
249
|
+
if (opts.sandboxDecks) {
|
|
250
|
+
headers["content-security-policy"] = "sandbox allow-scripts allow-same-origin allow-downloads; connect-src 'none'";
|
|
251
|
+
headers["cross-origin-resource-policy"] = "same-site";
|
|
252
|
+
}
|
|
253
|
+
const range = parseRange(req.headers.range, info.size);
|
|
254
|
+
if (range === "unsatisfiable") {
|
|
255
|
+
res.writeHead(416, { "content-range": `bytes */${info.size}` });
|
|
256
|
+
res.end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (range) {
|
|
260
|
+
headers["content-range"] = `bytes ${range.start}-${range.end}/${info.size}`;
|
|
261
|
+
headers["content-length"] = String(range.end - range.start + 1);
|
|
262
|
+
res.writeHead(206, headers);
|
|
263
|
+
if (req.method === "HEAD") return void res.end();
|
|
264
|
+
createReadStream(file, range).pipe(res);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
headers["content-length"] = String(info.size);
|
|
268
|
+
res.writeHead(200, headers);
|
|
269
|
+
if (req.method === "HEAD") return void res.end();
|
|
270
|
+
createReadStream(file).pipe(res);
|
|
271
|
+
}
|
|
272
|
+
function fail(res, err) {
|
|
273
|
+
if (res.headersSent) return void res.end();
|
|
274
|
+
if (err instanceof UploadError || err instanceof QueueFullError) {
|
|
275
|
+
send(res, err.status, { error: { message: err.message, hint: err.hint } });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
opts.log(`error: ${err instanceof Error ? err.stack : String(err)}`);
|
|
279
|
+
send(res, 500, {
|
|
280
|
+
error: {
|
|
281
|
+
message: "The server could not handle that request.",
|
|
282
|
+
hint: "If it repeats, the server log has the detail; nothing about your document is wrong that we can name."
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return { server, queue };
|
|
287
|
+
}
|
|
288
|
+
function send(res, status, body) {
|
|
289
|
+
const text = JSON.stringify(body);
|
|
290
|
+
res.writeHead(status, {
|
|
291
|
+
"content-type": "application/json; charset=utf-8",
|
|
292
|
+
"content-length": Buffer.byteLength(text)
|
|
293
|
+
});
|
|
294
|
+
res.end(text);
|
|
295
|
+
}
|
|
296
|
+
function safeUrlPath(rest) {
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const raw of rest.split("/")) {
|
|
299
|
+
if (raw === "") continue;
|
|
300
|
+
let seg;
|
|
301
|
+
try {
|
|
302
|
+
seg = decodeURIComponent(raw);
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
if (seg === ".") continue;
|
|
307
|
+
if (seg === ".." || seg.includes("/") || seg.includes("\\") || seg.includes("\0")) return null;
|
|
308
|
+
out.push(seg);
|
|
309
|
+
}
|
|
310
|
+
return out.join("/");
|
|
311
|
+
}
|
|
312
|
+
function parseRange(header, size) {
|
|
313
|
+
const m = header?.match(/^bytes=(\d*)-(\d*)$/);
|
|
314
|
+
if (!m) return null;
|
|
315
|
+
const [, rawStart = "", rawEnd = ""] = m;
|
|
316
|
+
if (rawStart === "" && rawEnd === "") return null;
|
|
317
|
+
let start;
|
|
318
|
+
let end;
|
|
319
|
+
if (rawStart === "") {
|
|
320
|
+
start = Math.max(0, size - Number(rawEnd));
|
|
321
|
+
end = size - 1;
|
|
322
|
+
} else {
|
|
323
|
+
start = Number(rawStart);
|
|
324
|
+
end = rawEnd === "" ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
|
325
|
+
}
|
|
326
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
|
|
327
|
+
return "unsatisfiable";
|
|
328
|
+
}
|
|
329
|
+
return { start, end };
|
|
330
|
+
}
|
|
331
|
+
class RateLimiter {
|
|
332
|
+
constructor(limit, windowMs, now = Date.now) {
|
|
333
|
+
this.limit = limit;
|
|
334
|
+
this.windowMs = windowMs;
|
|
335
|
+
this.now = now;
|
|
336
|
+
}
|
|
337
|
+
#hits = /* @__PURE__ */ new Map();
|
|
338
|
+
take(key) {
|
|
339
|
+
const at = this.now();
|
|
340
|
+
const row = this.#hits.get(key);
|
|
341
|
+
if (!row || row.resetAt <= at) {
|
|
342
|
+
if (this.#hits.size > 1e4) {
|
|
343
|
+
for (const [k, v] of this.#hits) if (v.resetAt <= at) this.#hits.delete(k);
|
|
344
|
+
}
|
|
345
|
+
this.#hits.set(key, { count: 1, resetAt: at + this.windowMs });
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
if (row.count >= this.limit) return false;
|
|
349
|
+
row.count++;
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function ipOf(req) {
|
|
354
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
355
|
+
}
|
|
356
|
+
const MIME = {
|
|
357
|
+
".html": "text/html; charset=utf-8",
|
|
358
|
+
".css": "text/css; charset=utf-8",
|
|
359
|
+
".js": "text/javascript; charset=utf-8",
|
|
360
|
+
".json": "application/json; charset=utf-8",
|
|
361
|
+
".map": "application/json; charset=utf-8",
|
|
362
|
+
".svg": "image/svg+xml",
|
|
363
|
+
".png": "image/png",
|
|
364
|
+
".jpg": "image/jpeg",
|
|
365
|
+
".jpeg": "image/jpeg",
|
|
366
|
+
".gif": "image/gif",
|
|
367
|
+
".webp": "image/webp",
|
|
368
|
+
".woff2": "font/woff2",
|
|
369
|
+
".woff": "font/woff",
|
|
370
|
+
".mp3": "audio/mpeg",
|
|
371
|
+
".mp4": "video/mp4",
|
|
372
|
+
".webm": "video/webm",
|
|
373
|
+
".srt": "text/plain; charset=utf-8",
|
|
374
|
+
".vtt": "text/vtt; charset=utf-8",
|
|
375
|
+
".deck": "application/zip",
|
|
376
|
+
".txt": "text/plain; charset=utf-8",
|
|
377
|
+
".md": "text/markdown; charset=utf-8"
|
|
378
|
+
};
|
|
379
|
+
let cachedPage;
|
|
380
|
+
async function uiPage(log) {
|
|
381
|
+
if (cachedPage) return cachedPage;
|
|
382
|
+
const specifier = "./ui.js";
|
|
383
|
+
try {
|
|
384
|
+
const mod = await import(specifier);
|
|
385
|
+
const page = mod.uiPage ?? mod.PAGE ?? mod.page ?? mod.default;
|
|
386
|
+
const html = typeof page === "function" ? page() : page;
|
|
387
|
+
if (typeof html === "string" && html.trim() !== "") {
|
|
388
|
+
cachedPage = html;
|
|
389
|
+
return html;
|
|
390
|
+
}
|
|
391
|
+
log(`decksmith: ${specifier} exports no page; serving the stand-in uploader`);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
log(
|
|
394
|
+
`decksmith: cannot load ${specifier} (${err instanceof Error ? err.message : String(err)}); serving the stand-in uploader`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
cachedPage = FALLBACK_PAGE;
|
|
398
|
+
return cachedPage;
|
|
399
|
+
}
|
|
400
|
+
const FALLBACK_PAGE = `<!doctype html>
|
|
401
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
402
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
403
|
+
<title>DeckSmith</title>
|
|
404
|
+
<style>
|
|
405
|
+
body{font:15px/1.5 system-ui,sans-serif;max-width:44rem;margin:3rem auto;padding:0 1rem;color:#111}
|
|
406
|
+
fieldset{border:1px solid #ddd;border-radius:6px;margin:0 0 1rem}
|
|
407
|
+
label{display:inline-block;margin:.25rem 1rem .25rem 0}
|
|
408
|
+
#steps li{font-variant-numeric:tabular-nums}
|
|
409
|
+
#log{background:#111;color:#ddd;padding:.75rem;border-radius:6px;max-height:16rem;overflow:auto;white-space:pre-wrap;font:12px/1.4 ui-monospace,monospace}
|
|
410
|
+
.err{background:#fee;border:1px solid #c00;padding:.75rem;border-radius:6px}
|
|
411
|
+
</style></head><body>
|
|
412
|
+
<h1>DeckSmith</h1>
|
|
413
|
+
<p>A document in, an animated deck out. Markdown, or a zip holding one.</p>
|
|
414
|
+
<form id="f">
|
|
415
|
+
<fieldset><legend>Document</legend>
|
|
416
|
+
<input type="file" name="file" accept=".md,.markdown,.txt,.zip" required>
|
|
417
|
+
</fieldset>
|
|
418
|
+
<fieldset><legend>Options</legend>
|
|
419
|
+
<label>Format <select name="format" id="format"></select></label>
|
|
420
|
+
<label>Theme <select name="theme" id="theme"></select></label>
|
|
421
|
+
<label>Slides <input type="number" name="slides" min="3" max="40" value="12" style="width:5rem"></label>
|
|
422
|
+
<label><input type="checkbox" name="narrate" value="true"> Narrate</label>
|
|
423
|
+
<label><input type="checkbox" name="video" value="true"> Video</label>
|
|
424
|
+
</fieldset>
|
|
425
|
+
<button>Make the deck</button>
|
|
426
|
+
</form>
|
|
427
|
+
<div id="out"></div>
|
|
428
|
+
<script>
|
|
429
|
+
const $ = (s) => document.querySelector(s);
|
|
430
|
+
// EVERY string below originates in an upload \u2014 a filename, a zip entry name, a
|
|
431
|
+
// figure id the planner echoed back \u2014 so nothing reaches the DOM as markup.
|
|
432
|
+
// This page is served from the same origin as the API; an unescaped error
|
|
433
|
+
// message would be reflected XSS with the attacker choosing the filename.
|
|
434
|
+
const el = (tag, text, attrs) => {
|
|
435
|
+
const n = document.createElement(tag);
|
|
436
|
+
if (text != null) n.textContent = text;
|
|
437
|
+
for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, v);
|
|
438
|
+
return n;
|
|
439
|
+
};
|
|
440
|
+
const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
|
|
441
|
+
const errorBox = (e) => {
|
|
442
|
+
const box = el("div", null, { class: "err" });
|
|
443
|
+
box.append(el("b", e.message), el("br"), document.createTextNode(e.hint || ""));
|
|
444
|
+
return box;
|
|
445
|
+
};
|
|
446
|
+
fetch("/api/formats").then(r => r.json()).then(c => {
|
|
447
|
+
for (const f of c.formats) $("#format").append(el("option", f.id + " \u2014 " + f.width + "\xD7" + f.height, { value: f.id }));
|
|
448
|
+
for (const t of c.themes) $("#theme").append(el("option", t));
|
|
449
|
+
});
|
|
450
|
+
$("#f").addEventListener("submit", async (e) => {
|
|
451
|
+
e.preventDefault();
|
|
452
|
+
clear($("#out")).append(el("p", "Uploading\u2026"));
|
|
453
|
+
const res = await fetch("/api/jobs", { method: "POST", body: new FormData(e.target) });
|
|
454
|
+
const body = await res.json();
|
|
455
|
+
if (!res.ok) { clear($("#out")).append(errorBox(body.error)); return; }
|
|
456
|
+
watch(body.id);
|
|
457
|
+
});
|
|
458
|
+
function watch(id) {
|
|
459
|
+
clear($("#out")).append(el("ul", null, { id: "steps" }), el("p", null, { id: "where" }), el("pre", null, { id: "log" }));
|
|
460
|
+
const es = new EventSource("/api/jobs/" + encodeURIComponent(id) + "/events");
|
|
461
|
+
es.onmessage = (m) => draw(JSON.parse(m.data), es);
|
|
462
|
+
es.onerror = () => { es.close(); poll(id); };
|
|
463
|
+
}
|
|
464
|
+
async function poll(id) {
|
|
465
|
+
const v = await (await fetch("/api/jobs/" + encodeURIComponent(id))).json();
|
|
466
|
+
draw(v, null);
|
|
467
|
+
if (v.state === "queued" || v.state === "running") setTimeout(() => poll(id), 2000);
|
|
468
|
+
}
|
|
469
|
+
const ICON = { pending: "\u2026", running: "\u25B6", done: "\u2713", skipped: "\u2013", error: "\u2717" };
|
|
470
|
+
function draw(v, es) {
|
|
471
|
+
const secs = (n) => (n / 1000).toFixed(0) + "s";
|
|
472
|
+
const steps = clear($("#steps"));
|
|
473
|
+
for (const s of v.steps) {
|
|
474
|
+
steps.append(el("li", ICON[s.state] + " " + s.name + (s.ms ? " " + secs(s.ms) : "") + (s.detail ? " \u2014 " + s.detail : "")));
|
|
475
|
+
}
|
|
476
|
+
const where = clear($("#where"));
|
|
477
|
+
where.textContent = v.state === "queued"
|
|
478
|
+
? "queued, position " + v.queuePosition
|
|
479
|
+
: v.state + (v.stage ? " \xB7 " + v.stage : "") + " \xB7 " + secs(v.ms) + " elapsed";
|
|
480
|
+
$("#log").textContent = v.log.join("\\n");
|
|
481
|
+
$("#log").scrollTop = 1e9;
|
|
482
|
+
if (v.error) clear(where).append(errorBox(v.error));
|
|
483
|
+
if (v.result) {
|
|
484
|
+
clear(where).append(el("span", v.result.slides + " slides, " + v.result.duration.toFixed(0) + "s \u2014 "));
|
|
485
|
+
for (const [label, href] of [["Open the deck", v.result.deckUrl], ["Video", v.result.videoUrl], ["Subtitles", v.result.srtUrl], ["Pack", v.result.packUrl]]) {
|
|
486
|
+
// Server-minted, same-origin paths only; anything else is not a link.
|
|
487
|
+
if (href && href.startsWith("/d/")) where.append(el("a", label, { href, target: "_blank" }), document.createTextNode(" "));
|
|
488
|
+
}
|
|
489
|
+
if (v.result.warnings.length) {
|
|
490
|
+
const ul = el("ul");
|
|
491
|
+
for (const w of v.result.warnings) ul.append(el("li", w));
|
|
492
|
+
where.append(ul);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (es && (v.state === "done" || v.state === "error")) es.close();
|
|
496
|
+
}
|
|
497
|
+
</script></body></html>
|
|
498
|
+
`;
|
|
499
|
+
export {
|
|
500
|
+
RateLimiter,
|
|
501
|
+
createDeckServer,
|
|
502
|
+
parseRange,
|
|
503
|
+
safeUrlPath
|
|
504
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createDeckServer } from "./http.js";
|
|
7
|
+
import { MAX_UPLOAD_BYTES } from "./upload.js";
|
|
8
|
+
const env = process.env;
|
|
9
|
+
const options = {
|
|
10
|
+
port: int(env.PORT, 8475),
|
|
11
|
+
host: env.DECKSMITH_HOST ?? "127.0.0.1",
|
|
12
|
+
work: env.DECKSMITH_WORK ?? join(tmpdir(), "decksmith-server"),
|
|
13
|
+
maxUploadBytes: int(env.DECKSMITH_MAX_UPLOAD, MAX_UPLOAD_BYTES),
|
|
14
|
+
maxQueued: int(env.DECKSMITH_MAX_QUEUE, 8),
|
|
15
|
+
ttlMs: int(env.DECKSMITH_JOB_TTL_MIN, 120) * 6e4,
|
|
16
|
+
jobsPerHour: int(env.DECKSMITH_JOBS_PER_HOUR, 5),
|
|
17
|
+
requestsPerMinute: int(env.DECKSMITH_REQS_PER_MIN, 240),
|
|
18
|
+
fetchRemoteFigures: flag(env.DECKSMITH_FETCH_FIGURES, true),
|
|
19
|
+
sandboxDecks: flag(env.DECKSMITH_DECK_SANDBOX, true),
|
|
20
|
+
removeDir: (dir) => void rm(dir, { recursive: true, force: true }).catch(() => {
|
|
21
|
+
}),
|
|
22
|
+
log: (line) => process.stderr.write(`${line}
|
|
23
|
+
`)
|
|
24
|
+
};
|
|
25
|
+
mkdirSync(options.work, { recursive: true });
|
|
26
|
+
const { server, queue } = createDeckServer(options);
|
|
27
|
+
const sweeper = setInterval(() => queue.sweep(), 6e4);
|
|
28
|
+
sweeper.unref();
|
|
29
|
+
function sweepOrphans() {
|
|
30
|
+
const cutoff = Date.now() - options.ttlMs;
|
|
31
|
+
for (const entry of readdirSync(options.work, { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isDirectory()) continue;
|
|
33
|
+
const dir = join(options.work, entry.name);
|
|
34
|
+
if (!existsSync(join(dir, "deck"))) {
|
|
35
|
+
if (existsSync(join(dir, "job.json"))) {
|
|
36
|
+
for (const derived of ["src", "audio", "deck", "upload", "storyboard.json"]) {
|
|
37
|
+
options.removeDir(join(dir, derived));
|
|
38
|
+
}
|
|
39
|
+
options.log(`decksmith: ${entry.name} interrupted \u2014 upload kept, it can be run again`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
options.removeDir(dir);
|
|
43
|
+
options.log(`decksmith: dropped ${entry.name} \u2014 interrupted, nothing to keep`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (statSync(dir).mtimeMs > cutoff) continue;
|
|
47
|
+
options.removeDir(dir);
|
|
48
|
+
options.log(`decksmith: swept ${entry.name} from a previous run`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
sweepOrphans();
|
|
52
|
+
server.listen(options.port, options.host, () => {
|
|
53
|
+
options.log(`decksmith: http://${options.host}:${options.port}`);
|
|
54
|
+
options.log(`decksmith: work ${options.work}, one job at a time, ${options.maxQueued} may wait`);
|
|
55
|
+
options.log(
|
|
56
|
+
`decksmith: no auth, no TLS. Bound to ${options.host}${options.host === "127.0.0.1" ? " \u2014 set DECKSMITH_HOST to open it, knowing that" : " \u2014 anyone who can reach this port can spend your Codex quota"}.`
|
|
57
|
+
);
|
|
58
|
+
preflight();
|
|
59
|
+
});
|
|
60
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
61
|
+
process.on(signal, () => {
|
|
62
|
+
options.log("decksmith: stopping");
|
|
63
|
+
server.close(() => process.exit(0));
|
|
64
|
+
setTimeout(() => process.exit(0), 5e3).unref();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function preflight() {
|
|
68
|
+
const runtime = fileURLToPath(new URL("../deck-runtime.js", import.meta.url));
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!existsSync(runtime)) missing.push(`the deck runtime (${runtime}) \u2014 run \`npm run build\``);
|
|
71
|
+
for (const [bin, why] of [
|
|
72
|
+
["codex", "planning; every job needs it"],
|
|
73
|
+
["edge-tts", "narration"],
|
|
74
|
+
["ffmpeg", "video"]
|
|
75
|
+
]) {
|
|
76
|
+
if (!onPath(bin)) missing.push(`${bin} \u2014 needed for ${why}`);
|
|
77
|
+
}
|
|
78
|
+
for (const line of missing) options.log(`decksmith: MISSING ${line}`);
|
|
79
|
+
if (missing.length === 0) options.log("decksmith: codex, edge-tts and ffmpeg all found");
|
|
80
|
+
}
|
|
81
|
+
function onPath(bin) {
|
|
82
|
+
return (env.PATH ?? "").split(":").some((dir) => dir !== "" && existsSync(join(dir, bin)));
|
|
83
|
+
}
|
|
84
|
+
function int(raw, fallback) {
|
|
85
|
+
const n = Number(raw);
|
|
86
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
87
|
+
}
|
|
88
|
+
function flag(raw, fallback) {
|
|
89
|
+
if (raw === void 0) return fallback;
|
|
90
|
+
return ["1", "true", "on", "yes"].includes(raw.toLowerCase());
|
|
91
|
+
}
|