@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,232 @@
|
|
|
1
|
+
import { posix, sep } from "node:path";
|
|
2
|
+
import { unzipSync } from "fflate";
|
|
3
|
+
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
4
|
+
const ZIP_LIMITS = {
|
|
5
|
+
maxEntries: 500,
|
|
6
|
+
maxTotalBytes: 200 * 1024 * 1024,
|
|
7
|
+
maxEntryBytes: 64 * 1024 * 1024
|
|
8
|
+
};
|
|
9
|
+
const MARKDOWN_EXTS = [".md", ".markdown", ".txt"];
|
|
10
|
+
class UploadError extends Error {
|
|
11
|
+
hint;
|
|
12
|
+
status;
|
|
13
|
+
constructor(message, hint, status = 400) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "UploadError";
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const GRACE = 4;
|
|
21
|
+
function readBody(req, limit = MAX_UPLOAD_BYTES) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let size = 0;
|
|
25
|
+
let refused = false;
|
|
26
|
+
req.on("data", (chunk) => {
|
|
27
|
+
size += chunk.length;
|
|
28
|
+
if (refused) {
|
|
29
|
+
if (size > limit * GRACE) req.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (size > limit) {
|
|
33
|
+
refused = true;
|
|
34
|
+
chunks.length = 0;
|
|
35
|
+
reject(
|
|
36
|
+
new UploadError(
|
|
37
|
+
`Upload is larger than ${Math.round(limit / 1024 / 1024)} MB.`,
|
|
38
|
+
"Send the markdown on its own, or a zip holding only the document and the figures it cites.",
|
|
39
|
+
413
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
});
|
|
46
|
+
req.on("end", () => {
|
|
47
|
+
if (!refused) resolve(Buffer.concat(chunks));
|
|
48
|
+
});
|
|
49
|
+
req.on("error", (err) => {
|
|
50
|
+
if (!refused) reject(err);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async function parseMultipart(body, contentType) {
|
|
55
|
+
if (!/^multipart\/form-data\s*;/i.test(contentType)) {
|
|
56
|
+
throw new UploadError(
|
|
57
|
+
"This endpoint takes a multipart/form-data upload.",
|
|
58
|
+
'Post a form with a "file" part holding the document, e.g. `curl -F file=@paper.md`.',
|
|
59
|
+
415
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let form;
|
|
63
|
+
try {
|
|
64
|
+
const view = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
65
|
+
form = await new Response(view, { headers: { "content-type": contentType } }).formData();
|
|
66
|
+
} catch {
|
|
67
|
+
throw new UploadError(
|
|
68
|
+
"The multipart body could not be parsed.",
|
|
69
|
+
"Let your HTTP client set the Content-Type and boundary rather than writing them by hand."
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const fields = {};
|
|
73
|
+
let file;
|
|
74
|
+
form.forEach((value, key) => {
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
fields[key] = value;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key === "file") file = value;
|
|
80
|
+
});
|
|
81
|
+
if (!file) {
|
|
82
|
+
throw new UploadError(
|
|
83
|
+
'The upload has no "file" part.',
|
|
84
|
+
'Name the document part "file" \u2014 .md, .markdown, .txt, or a .zip containing one.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (file.size === 0) {
|
|
88
|
+
throw new UploadError(
|
|
89
|
+
`"${file.name || "the uploaded file"}" is empty.`,
|
|
90
|
+
"Check the path you gave your client; an empty part usually means the file was not found."
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
filename: file.name || "upload",
|
|
95
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
96
|
+
fields
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function looksLikeZip(bytes) {
|
|
100
|
+
return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75;
|
|
101
|
+
}
|
|
102
|
+
function safeEntryPath(name) {
|
|
103
|
+
if (name.includes("\0")) return null;
|
|
104
|
+
const segments = name.replace(/\\/g, "/").split("/");
|
|
105
|
+
if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
|
|
106
|
+
const kept = [];
|
|
107
|
+
for (const segment of segments) {
|
|
108
|
+
if (segment === "" || segment === ".") continue;
|
|
109
|
+
if (segment === "..") return null;
|
|
110
|
+
kept.push(segment);
|
|
111
|
+
}
|
|
112
|
+
if (kept.length === 0) return null;
|
|
113
|
+
const path = kept.join("/");
|
|
114
|
+
if (path.length > 512 || kept.some((s) => s.length > 200)) return null;
|
|
115
|
+
return path;
|
|
116
|
+
}
|
|
117
|
+
function insideRoot(root, joined) {
|
|
118
|
+
return joined === root || joined.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
119
|
+
}
|
|
120
|
+
function readZip(bytes, limits = ZIP_LIMITS) {
|
|
121
|
+
const seen = [];
|
|
122
|
+
try {
|
|
123
|
+
unzipSync(bytes, {
|
|
124
|
+
filter: (f) => {
|
|
125
|
+
seen.push({ name: f.name, originalSize: f.originalSize });
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
throw new UploadError(
|
|
131
|
+
"That file starts like a zip but could not be read as one.",
|
|
132
|
+
"Re-create the archive \u2014 a truncated download and a renamed .rar both look like this."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const warnings = [];
|
|
136
|
+
const approved = /* @__PURE__ */ new Map();
|
|
137
|
+
let total = 0;
|
|
138
|
+
for (const entry of seen) {
|
|
139
|
+
if (entry.name.endsWith("/")) continue;
|
|
140
|
+
if (isNoise(entry.name)) continue;
|
|
141
|
+
const safe = safeEntryPath(entry.name);
|
|
142
|
+
if (safe === null) {
|
|
143
|
+
throw new UploadError(
|
|
144
|
+
`The archive contains an entry that tries to escape its directory: "${entry.name}".`,
|
|
145
|
+
"Re-zip from inside the folder so every path is relative and none of them start with / or contain .."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (entry.originalSize > limits.maxEntryBytes) {
|
|
149
|
+
throw new UploadError(
|
|
150
|
+
`"${entry.name}" unpacks to ${mb(entry.originalSize)}, over the ${mb(limits.maxEntryBytes)} per-file limit.`,
|
|
151
|
+
"Leave the large file out; a deck reads the document and its figures, not the dataset."
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
total += entry.originalSize;
|
|
155
|
+
if (total > limits.maxTotalBytes) {
|
|
156
|
+
throw new UploadError(
|
|
157
|
+
`The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
|
|
158
|
+
"Send only the document and the figures it cites."
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (approved.size >= limits.maxEntries) {
|
|
162
|
+
throw new UploadError(
|
|
163
|
+
`The archive holds more than ${limits.maxEntries} files.`,
|
|
164
|
+
"Send only the document and the figures it cites."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
approved.set(entry.name, safe);
|
|
168
|
+
}
|
|
169
|
+
if (approved.size === 0) {
|
|
170
|
+
throw new UploadError(
|
|
171
|
+
"The archive is empty.",
|
|
172
|
+
"Zip the folder that holds your markdown, not an empty one."
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const raw = unzipSync(bytes, { filter: (f) => approved.has(f.name) });
|
|
176
|
+
const files = {};
|
|
177
|
+
let actual = 0;
|
|
178
|
+
for (const [name, safe] of approved) {
|
|
179
|
+
const data = raw[name];
|
|
180
|
+
if (!data) continue;
|
|
181
|
+
actual += data.length;
|
|
182
|
+
if (actual > limits.maxTotalBytes) {
|
|
183
|
+
throw new UploadError(
|
|
184
|
+
`The archive unpacks to more than ${mb(limits.maxTotalBytes)}.`,
|
|
185
|
+
"Send only the document and the figures it cites."
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (files[safe]) warnings.push(`two entries both unpack to ${safe}; kept the last`);
|
|
189
|
+
files[safe] = data;
|
|
190
|
+
}
|
|
191
|
+
return { files, warnings };
|
|
192
|
+
}
|
|
193
|
+
function pickMarkdown(files) {
|
|
194
|
+
const candidates = Object.keys(files).filter(
|
|
195
|
+
(p) => MARKDOWN_EXTS.includes(posix.extname(p).toLowerCase())
|
|
196
|
+
);
|
|
197
|
+
if (candidates.length === 0) {
|
|
198
|
+
throw new UploadError(
|
|
199
|
+
`The archive has no ${MARKDOWN_EXTS.join(", ")} file in it.`,
|
|
200
|
+
"DeckSmith reads a markdown document. Add the .md next to your figures and zip the folder again."
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const rank = (p) => {
|
|
204
|
+
const base = posix.basename(p).toLowerCase();
|
|
205
|
+
const named = /^(readme|index|main|paper|analysis)\./.test(base) ? 0 : 1;
|
|
206
|
+
return [p.split("/").length, named, p];
|
|
207
|
+
};
|
|
208
|
+
return candidates.sort((a, b) => {
|
|
209
|
+
const [da, na, pa] = rank(a);
|
|
210
|
+
const [db, nb, pb] = rank(b);
|
|
211
|
+
return da - db || na - nb || pa.localeCompare(pb);
|
|
212
|
+
})[0];
|
|
213
|
+
}
|
|
214
|
+
function isNoise(name) {
|
|
215
|
+
return name.startsWith("__MACOSX/") || name.includes("/__MACOSX/") || posix.basename(name) === ".DS_Store" || posix.basename(name) === "Thumbs.db";
|
|
216
|
+
}
|
|
217
|
+
function mb(bytes) {
|
|
218
|
+
return `${Math.round(bytes / 1024 / 1024)} MB`;
|
|
219
|
+
}
|
|
220
|
+
export {
|
|
221
|
+
MARKDOWN_EXTS,
|
|
222
|
+
MAX_UPLOAD_BYTES,
|
|
223
|
+
UploadError,
|
|
224
|
+
ZIP_LIMITS,
|
|
225
|
+
insideRoot,
|
|
226
|
+
looksLikeZip,
|
|
227
|
+
parseMultipart,
|
|
228
|
+
pickMarkdown,
|
|
229
|
+
readBody,
|
|
230
|
+
readZip,
|
|
231
|
+
safeEntryPath
|
|
232
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** One entry of the slideshow island's `slides` array. */
|
|
2
|
+
export interface SlideSpec {
|
|
3
|
+
sceneId: string;
|
|
4
|
+
/** Absolute seconds. Present when the island lives outside the composition. */
|
|
5
|
+
startTime?: number;
|
|
6
|
+
endTime?: number;
|
|
7
|
+
/** Absolute positions on the deck timeline, inside this slide's window. */
|
|
8
|
+
fragments?: number[];
|
|
9
|
+
notes?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Stop {
|
|
12
|
+
/** Absolute position on the deck timeline, in seconds. */
|
|
13
|
+
t: number;
|
|
14
|
+
/** 0-based, over the slides that could be placed. */
|
|
15
|
+
slide: number;
|
|
16
|
+
/** 0 is the slide itself; 1..n are its fragments in time order. */
|
|
17
|
+
fragment: number;
|
|
18
|
+
notes: string;
|
|
19
|
+
/**
|
|
20
|
+
* The scene this stop belongs to. Carried through because it is the only id
|
|
21
|
+
* shared with the narration island — `slide` is a position over the slides we
|
|
22
|
+
* could place, which shifts the moment one of them is unplaceable.
|
|
23
|
+
*/
|
|
24
|
+
sceneId: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Pos {
|
|
27
|
+
slide: number;
|
|
28
|
+
fragment: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Flatten the island into the ordered list of positions a presenter steps
|
|
32
|
+
* through: each slide's start, then each of its fragments.
|
|
33
|
+
*
|
|
34
|
+
* Placement comes from the island alone. This code only ever runs in the wrapper
|
|
35
|
+
* page, where the scene divs are inside the player's iframe and unreachable — so
|
|
36
|
+
* `emitIsland` always writes `startTime`/`endTime`, and there is nothing to
|
|
37
|
+
* scrape from the DOM.
|
|
38
|
+
*/
|
|
39
|
+
export declare function buildStops(slides: readonly SlideSpec[]): Stop[];
|
|
40
|
+
export interface TransitionPlan {
|
|
41
|
+
animate: boolean;
|
|
42
|
+
/** Wall-clock milliseconds to spend. 0 whenever `animate` is false. */
|
|
43
|
+
durationMs: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Decide whether a step plays or cuts. Pure, so the policy is testable without
|
|
47
|
+
* a DOM; the rAF loop that obeys it is not.
|
|
48
|
+
*/
|
|
49
|
+
export declare function planTransition(fromT: number, toT: number, opts?: {
|
|
50
|
+
reducedMotion?: boolean;
|
|
51
|
+
}): TransitionPlan;
|
|
52
|
+
/** `#3` is slide 3; `#3.2` is slide 3, second fragment. Both 1-based. */
|
|
53
|
+
export declare function formatHash(pos: Pos): string;
|
|
54
|
+
export declare function parseHash(hash: string): Pos | null;
|
|
55
|
+
/**
|
|
56
|
+
* Index of `pos` in the stop list. A deep link that names a fragment we no
|
|
57
|
+
* longer emit falls back to its slide rather than to nothing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function findStop(stops: readonly Stop[], pos: Pos): number;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narration island, and the two questions the presented deck asks of it.
|
|
3
|
+
*
|
|
4
|
+
* A narrated deck carries a second JSON island beside the slideshow one: same
|
|
5
|
+
* page, same shape of thing, different reader. It is separate rather than folded
|
|
6
|
+
* into the slideshow manifest because that manifest is HyperFrames' format, not
|
|
7
|
+
* ours — adding a key to it is a bet on a schema someone else owns.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure, and deliberately so. Playback is untestable without a
|
|
10
|
+
* browser; "which cue is on screen at t" and "which segment belongs to this
|
|
11
|
+
* stop" are the parts that can actually be wrong, so they live apart from the
|
|
12
|
+
* audio element and are tested directly.
|
|
13
|
+
*
|
|
14
|
+
* Read defensively throughout. A deck built before narration existed has no
|
|
15
|
+
* island at all, and a deck built by a newer emitter may carry fields this
|
|
16
|
+
* reader has never heard of; neither may do anything worse than fall silent.
|
|
17
|
+
*/
|
|
18
|
+
/** Selector for the island. Not `+json` alone — the slideshow one is that too. */
|
|
19
|
+
export declare const NARRATION_ISLAND = "script[type=\"application/decksmith-narration+json\"]";
|
|
20
|
+
/** One subtitle line, in seconds from the start of its own segment's audio. */
|
|
21
|
+
export interface Cue {
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What is spoken at one stop.
|
|
28
|
+
*
|
|
29
|
+
* `stop` matches `Stop.fragment`: 0 is the slide's landing, 1..n its reveals.
|
|
30
|
+
* The contract's `seconds` is not read here — the audio element's own clock is
|
|
31
|
+
* the authority, and a measured number we then ignore is a number that can drift
|
|
32
|
+
* out of agreement with the file without anything noticing.
|
|
33
|
+
*/
|
|
34
|
+
export interface Segment {
|
|
35
|
+
stop: number;
|
|
36
|
+
/** Resolved against `Narration.dir`; see `audioSrc`. */
|
|
37
|
+
audio: string;
|
|
38
|
+
cues: Cue[];
|
|
39
|
+
}
|
|
40
|
+
export interface Narration {
|
|
41
|
+
voice: string;
|
|
42
|
+
/** Directory the segment paths hang off, relative to the deck page. May be "". */
|
|
43
|
+
dir: string;
|
|
44
|
+
/** Keyed by scene id — `s1`, `s2` — the same ids the slideshow island carries. */
|
|
45
|
+
scenes: Record<string, Segment[]>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The longest cue we will put on screen at once: two lines of about 42
|
|
49
|
+
* characters, which is broadcast practice and roughly what the band's width
|
|
50
|
+
* gives at its clamped font size.
|
|
51
|
+
*
|
|
52
|
+
* edge-tts emits one cue per SENTENCE, and a long sentence produces a long cue —
|
|
53
|
+
* 121 characters on a single wrapped block was measured on the demo deck. That
|
|
54
|
+
* is three lines, and three lines is a band tall enough to cover the bottom of
|
|
55
|
+
* the slide however much room the composition reserves. Capping here rather than
|
|
56
|
+
* at synthesis time means a deck built before this existed is fixed by reloading
|
|
57
|
+
* it, without re-narrating anything.
|
|
58
|
+
*/
|
|
59
|
+
export declare const CUE_MAX_CHARS = 84;
|
|
60
|
+
/**
|
|
61
|
+
* One cue, wrapped to at most `max` characters a piece, with the original
|
|
62
|
+
* duration divided between the pieces in proportion to their length.
|
|
63
|
+
*
|
|
64
|
+
* Proportional rather than equal because the reading time a chunk needs tracks
|
|
65
|
+
* its length, and because the speech under it does too — the split lands close
|
|
66
|
+
* to where the voice actually is. A word longer than `max` becomes its own
|
|
67
|
+
* piece rather than being broken mid-word.
|
|
68
|
+
*
|
|
69
|
+
* EVEN, not greedy. Packing straight to `max` fills the first piece and dumps
|
|
70
|
+
* whatever is left into the last one, and the duration split is proportional,
|
|
71
|
+
* so a 90-character cue came out as an 84-character caption followed by a
|
|
72
|
+
* SINGLE WORD on screen for 0.3 seconds. Six of the demo deck's forty-nine cues
|
|
73
|
+
* flashed a one-word caption that way — under every broadcast minimum, and
|
|
74
|
+
* clearly wrong the moment anyone watched the video. So the piece COUNT is
|
|
75
|
+
* decided first, from `max`, and then the narrowest width that still fits in
|
|
76
|
+
* that many pieces does the packing: same number of captions, no orphan.
|
|
77
|
+
*/
|
|
78
|
+
export declare function splitCue(cue: Cue, max?: number): Cue[];
|
|
79
|
+
/**
|
|
80
|
+
* Parse the island's text. Anything malformed reads as "no narration", which is
|
|
81
|
+
* the deck we shipped yesterday and is always a safe answer.
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseNarration(text: string | null | undefined): Narration | null;
|
|
84
|
+
/**
|
|
85
|
+
* The cue on screen at `t`, or null in a gap.
|
|
86
|
+
*
|
|
87
|
+
* Half-open: `[start, end)`. A cue ending exactly where the next begins must
|
|
88
|
+
* hand over cleanly, and closing both ends would put two lines on screen for one
|
|
89
|
+
* frame at every sentence boundary — which is every boundary edge-tts emits.
|
|
90
|
+
*/
|
|
91
|
+
export declare function activeCue(cues: readonly Cue[], t: number): Cue | null;
|
|
92
|
+
/** The segment spoken at one stop, or null where the deck is silent. */
|
|
93
|
+
export declare function segmentFor(narration: Narration | null, sceneId: string, stop: number): Segment | null;
|
|
94
|
+
/**
|
|
95
|
+
* Where the segment's audio actually lives, relative to the deck page.
|
|
96
|
+
*
|
|
97
|
+
* `dir` is a convenience so the emitter can write bare filenames; a segment path
|
|
98
|
+
* that is already absolute or already a URL is left exactly as it is, because
|
|
99
|
+
* prefixing one would break a deck whose audio is hosted rather than baked.
|
|
100
|
+
*/
|
|
101
|
+
export declare function audioSrc(narration: Narration, segment: Segment): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The source's own figure, with the explanation drawn onto it.
|
|
3
|
+
*
|
|
4
|
+
* `claim-figure` parks a screenshot beside a sentence and leaves the viewer to
|
|
5
|
+
* find the part being talked about. This archetype removes that search: each
|
|
6
|
+
* note is a dot on the exact pixel, a leader out to the nearest margin, and a
|
|
7
|
+
* label sitting on a coloured rule — revealed one at a time, so the figure is
|
|
8
|
+
* read in the order the argument needs rather than all at once.
|
|
9
|
+
*
|
|
10
|
+
* `notes[].x/y` are fractions of the figure, never pixels, so the overlay is
|
|
11
|
+
* solved in the figure's own space and stays correct at any layout size. That is
|
|
12
|
+
* only true if the displayed image box is known exactly, which is why the `<img>`
|
|
13
|
+
* gets explicit pixel dimensions derived here rather than `max-width:100%`: a box
|
|
14
|
+
* the browser chooses is a box this file cannot annotate.
|
|
15
|
+
*
|
|
16
|
+
* Margins either side of the figure is a landscape idea. In portrait two of them
|
|
17
|
+
* cost two thirds of an 860px stage, and the demo's figure came out 232px wide —
|
|
18
|
+
* a postage stamp in a tall black frame, which is the one thing a slide whose
|
|
19
|
+
* whole subject is a figure may not be. So portrait puts the figure across the
|
|
20
|
+
* full width and stacks the labels underneath it in two columns instead. The
|
|
21
|
+
* device is unchanged either way: a dot on the pixel, a leader out to a rule, and
|
|
22
|
+
* the label sitting on it. Only which way "out" points has moved.
|
|
23
|
+
*/
|
|
24
|
+
import type { Format } from "../../types.js";
|
|
25
|
+
import type { Emitter } from "../kit.js";
|
|
26
|
+
import type { Box, Pt } from "../svg.js";
|
|
27
|
+
export interface FigureNote {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
export interface NoteBox {
|
|
33
|
+
/** Which margin this label was given. */
|
|
34
|
+
side: "l" | "r";
|
|
35
|
+
lines: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Stage-space x of the rule's inner end — the one the leader arrives at, with
|
|
38
|
+
* the label running outward from it.
|
|
39
|
+
*
|
|
40
|
+
* Solved here rather than in the emitter because it is the one number the two
|
|
41
|
+
* arrangements disagree about: beside the plate in landscape, either side of
|
|
42
|
+
* the centre channel under the figure in portrait.
|
|
43
|
+
*/
|
|
44
|
+
inner: number;
|
|
45
|
+
/** Widest line — also the length of the rule the label sits on. */
|
|
46
|
+
w: number;
|
|
47
|
+
h: number;
|
|
48
|
+
/** Stage-space top of the text block. */
|
|
49
|
+
top: number;
|
|
50
|
+
/** Stage-space y of the rule, where the leader arrives from the dot. */
|
|
51
|
+
ruleY: number;
|
|
52
|
+
/** The annotated point, in stage space. */
|
|
53
|
+
at: Pt;
|
|
54
|
+
clipped: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface FigureLayout {
|
|
57
|
+
/** The image box in stage space. The plate is this grown by `PLATE` a side. */
|
|
58
|
+
img: Box;
|
|
59
|
+
/** Width available to a label in either margin. */
|
|
60
|
+
col: number;
|
|
61
|
+
/**
|
|
62
|
+
* What the stage actually needs, which is at most the budget it was solved
|
|
63
|
+
* against. A 4:1 strip uses 230px of a 726px budget, and a stage left at the
|
|
64
|
+
* budget puts a 250px dead band above and below the only thing on the slide.
|
|
65
|
+
*/
|
|
66
|
+
height: number;
|
|
67
|
+
boxes: NoteBox[];
|
|
68
|
+
/** False when a label had to be clipped or a stack had to overlap. */
|
|
69
|
+
ok: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Size the figure and place its labels.
|
|
73
|
+
*
|
|
74
|
+
* Exported because this is the one thing here that can be wrong without looking
|
|
75
|
+
* wrong in a diff: a label half a pixel past the stage, or two overlapping in a
|
|
76
|
+
* stack, is a property worth asserting directly rather than through rendered SVG.
|
|
77
|
+
*/
|
|
78
|
+
export declare function planFigure(stageW: number, notes: FigureNote[], fig: {
|
|
79
|
+
width: number;
|
|
80
|
+
height: number;
|
|
81
|
+
}, stageH: number, tall?: boolean): FigureLayout;
|
|
82
|
+
/**
|
|
83
|
+
* Height the stage may spend, once the chrome and the caption have taken theirs.
|
|
84
|
+
* A budget, not a size — `planFigure` gives back only what it used.
|
|
85
|
+
*
|
|
86
|
+
* TAKES THE HEADLINE, because the chrome's height is a fact about the headline.
|
|
87
|
+
* This charged a flat `HEAD_H` — one line — however many the headline wrapped to,
|
|
88
|
+
* so a three-line headline went 174px unaccounted and pushed the stage down
|
|
89
|
+
* until the caption rendered 81px BELOW the canvas and a note's second line 41px
|
|
90
|
+
* below. `chromeHeight` is the same measurement every other archetype makes.
|
|
91
|
+
*
|
|
92
|
+
* AND IT NO LONGER FLOORS AT 360, which was the same fix half-made. Charging the
|
|
93
|
+
* headline correctly stopped the caption falling off the canvas and left it
|
|
94
|
+
* being CRUSHED instead: under a three-line headline the honest remainder is
|
|
95
|
+
* 299px, the floor handed back 360, the stage took 345 of it, and `.af-cap` —
|
|
96
|
+
* a flex child that may shrink — was squeezed to a 1.7px box with
|
|
97
|
+
* `-webkit-line-clamp` quietly eating the text. `clipped_text` and
|
|
98
|
+
* `text_box_overflow`, on a slide whose provenance line is invisible. A floor
|
|
99
|
+
* that reports more room than exists does not create room; it only moves which
|
|
100
|
+
* gate notices.
|
|
101
|
+
*/
|
|
102
|
+
export declare function stageBudget(format: Format, eyebrow: string | undefined, headline: string, caption: string): number;
|
|
103
|
+
export declare const annotatedFigure: Emitter<"annotated-figure">;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magnitudes, drawn.
|
|
3
|
+
*
|
|
4
|
+
* A table of figures makes the viewer do arithmetic: "1.592 against 0.930" is two
|
|
5
|
+
* numbers and a subtraction. The same pair as two bars is one glance. So nothing
|
|
6
|
+
* here is decoration — the bar lengths carry the entire claim, which is why the
|
|
7
|
+
* scale is derived from the data and anchored at zero. A bar chart whose baseline
|
|
8
|
+
* is not zero is a chart that asserts a ratio the numbers do not support, and it
|
|
9
|
+
* would pass every gate we have.
|
|
10
|
+
*
|
|
11
|
+
* Three decisions worth knowing before changing anything:
|
|
12
|
+
*
|
|
13
|
+
* 1. Every bar sits in a full-width rail. The rail is what makes a dwarfed bar
|
|
14
|
+
* legible (see MIN_LEN) — the eye reads "almost none of the extent" rather
|
|
15
|
+
* than "nothing was drawn".
|
|
16
|
+
* 2. Values count up rather than fade in, and they count on the same decimal grid
|
|
17
|
+
* their printed string is on, so the number GSAP lands on is byte-identical to
|
|
18
|
+
* the one in the static SVG.
|
|
19
|
+
* 3. The bars are one reveal, not N. Holding on bar 3 of 8 asks the viewer to
|
|
20
|
+
* compare it against bars that have not been drawn yet.
|
|
21
|
+
*
|
|
22
|
+
* A label gutter beside the rails is a landscape idea. At 9:16 the demo's five
|
|
23
|
+
* names took 340px of an 860px box, so 40% of the width was spent naming bars
|
|
24
|
+
* that then had 60% left to differ in. Portrait puts the label on its own line
|
|
25
|
+
* above the rail instead and hands the whole width to the comparison — which is
|
|
26
|
+
* also where the height it needs comes from, since portrait has height to spare
|
|
27
|
+
* and width it does not.
|
|
28
|
+
*/
|
|
29
|
+
import type { Emitter } from "../kit.js";
|
|
30
|
+
export declare const barCompare: Emitter<"bar-compare">;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Labelled panels side by side — the archetype for the things a paper does not
|
|
3
|
+
* put in a figure: a contradiction between two tables, a caveat, a limit on what
|
|
4
|
+
* was actually tested. Panels appear one at a time so each can be spoken to.
|
|
5
|
+
*/
|
|
6
|
+
import type { Emitter } from "../kit.js";
|
|
7
|
+
export declare const callout: Emitter<"callout">;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A claim from the source, shown next to the figure that backs it.
|
|
3
|
+
*
|
|
4
|
+
* The layout is chosen from the figure's own aspect ratio rather than being a
|
|
5
|
+
* parameter: EXPERIMENT-002 full-bled a 1.98-aspect figure and pushed its
|
|
6
|
+
* caption 200px off-canvas. Only a genuine strip earns the full width.
|
|
7
|
+
*/
|
|
8
|
+
import type { Emitter } from "../kit.js";
|
|
9
|
+
export declare const claimFigure: Emitter<"claim-figure">;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table from the source, revealed row by row, then read for the viewer: the
|
|
3
|
+
* highlighted rows light up in the order the argument needs them.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here invents numbers, and nothing here drops them either: every row of
|
|
6
|
+
* the named table is drawn, at 40px or above, or the beat is refused. The type
|
|
7
|
+
* is solved from the WIDTH — upwards from the 40px floor, capped — and the height
|
|
8
|
+
* rule then asks whether those rows, drawn at that size, fit on the canvas. The
|
|
9
|
+
* one lever the height rule owns is the row padding, and it is already shut to
|
|
10
|
+
* `PAD_Y_MIN` whenever this refuses, so "will not fit" is a measurement of this
|
|
11
|
+
* slide rather than a preference about it.
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT DELIBERATELY DOES NOT DO: buy rows by taking the type back down toward
|
|
14
|
+
* 40. That would be legal — invariant 5 is a floor, not a fixed size, and a table
|
|
15
|
+
* declined here at 52px would often fit at 40 — but it makes `cell` solve two
|
|
16
|
+
* constraints at once, and the width solve is the one that has been measured. So
|
|
17
|
+
* the refusal names the size it refused AT, and the choice stays visible to
|
|
18
|
+
* whoever reads the error instead of being buried in this file.
|
|
19
|
+
*/
|
|
20
|
+
import type { Emitter } from "../kit.js";
|
|
21
|
+
export declare const dataTable: Emitter<"data-table">;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A field, and the windows that move over it.
|
|
3
|
+
*
|
|
4
|
+
* Attention spans, receptive fields, patches, pooling kernels, token sequences —
|
|
5
|
+
* every paper draws these as a rectangle over a lattice, and every slide deck
|
|
6
|
+
* then writes "8x8 shifted windows" in a bullet and calls it explained. The
|
|
7
|
+
* content of the idea is *which cells* and *how many of them*, and a sentence
|
|
8
|
+
* cannot show that; a lattice with the rectangle lit on it shows nothing else.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is solved from the box that is actually free after the chrome
|
|
11
|
+
* and the note, so a 24x16 feature map and a 2x1 pair of patches are the same
|
|
12
|
+
* code. A constant cell size would be right for one grid and wrong for the other
|
|
13
|
+
* twenty-three, and nobody would ever find out which.
|
|
14
|
+
*/
|
|
15
|
+
import type { Emitter } from "../kit.js";
|
|
16
|
+
export declare const grid: Emitter<"grid">;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The explanatory vocabulary, indexed by archetype.
|
|
3
|
+
*
|
|
4
|
+
* The mapped type is the point: adding an archetype to `Beat` without an emitter
|
|
5
|
+
* beside it fails to compile, which is the only guarantee that keeps the union in
|
|
6
|
+
* `types.ts` honest.
|
|
7
|
+
*/
|
|
8
|
+
import type { Archetype, Beat } from "../../types.js";
|
|
9
|
+
import type { EmitContext, Emitter, Scene } from "../kit.js";
|
|
10
|
+
export declare const emitters: {
|
|
11
|
+
[A in Archetype]: Emitter<A>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Dispatch a beat to its emitter. The cast is the one place the pairing is taken
|
|
15
|
+
* on trust: `emitters[beat.archetype]` is a union of twelve emitters and TypeScript
|
|
16
|
+
* will not narrow the key and the beat together. The table above already proves
|
|
17
|
+
* every archetype has exactly one emitter of the right shape.
|
|
18
|
+
*/
|
|
19
|
+
export declare function emitScene(beat: Beat, ctx: EmitContext): Scene;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chart the source never drew.
|
|
3
|
+
*
|
|
4
|
+
* Papers report sweeps as tables and leave the shape of the curve to the reader.
|
|
5
|
+
* Drawing it is the whole point of this archetype, so everything here is derived
|
|
6
|
+
* from the data — the scale especially. A hardcoded axis is how a chart ends up
|
|
7
|
+
* asserting something the numbers do not.
|
|
8
|
+
*/
|
|
9
|
+
import type { Emitter } from "../kit.js";
|
|
10
|
+
export interface Scale {
|
|
11
|
+
min: number;
|
|
12
|
+
max: number;
|
|
13
|
+
step: number;
|
|
14
|
+
decimals: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A round-numbered scale that provably contains every value. Exported because
|
|
18
|
+
* "the axis spans the data" is the one property a chart must never get wrong,
|
|
19
|
+
* and it is worth asserting directly rather than through rendered SVG.
|
|
20
|
+
*/
|
|
21
|
+
export declare function chartScale(values: number[]): Scale;
|
|
22
|
+
export declare const lineChart: Emitter<"line-chart">;
|