@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,290 @@
1
+ /**
2
+ * Where the words go.
3
+ *
4
+ * A narrated deck used to render SILENT: thirty-seven mp3s sat beside a deck
5
+ * whose mp4 had one stream. This module is the arithmetic that puts them back,
6
+ * and it is arithmetic rather than an ffmpeg incantation because the thing that
7
+ * can be wrong here is a number, not a codec. Audio that drifts from the
8
+ * animation is worse than no audio at all, so every offset below is derived
9
+ * from the composition the emitter actually wrote, not from a second guess at
10
+ * what it must have written.
11
+ *
12
+ * THE TIMING MODEL, and the one thing to understand before changing anything:
13
+ *
14
+ * A narration segment belongs to a STOP, and a stop is a hold — a settled time
15
+ * inside a scene where a presenter pauses. In the PRESENTED deck the runtime
16
+ * seeks to that hold and waits for the sentence to finish. A linear render has
17
+ * no waiting: the GSAP timeline runs straight through, so on the demo deck a
18
+ * six-reveal pipeline fires every reveal inside 7.75 seconds and then sits
19
+ * motionless for the 31 seconds of speech that follow. Placing the audio at the
20
+ * holds would overlap five-second sentences into 1.4-second gaps; placing it
21
+ * sequentially would leave the last sentence nineteen seconds behind the reveal
22
+ * it describes. Both are the drift this feature exists to avoid.
23
+ *
24
+ * So the renderer does what the presenter does: it FREEZES the video at each
25
+ * hold for exactly as long as that stop's audio, and drops the dead tail the
26
+ * composition parked at the end of the scene. That is not a liberty — it is
27
+ * what `beatSeconds` in src/emit/composition.ts already budgeted for. It makes
28
+ * a narrated scene `lastHold + sum(segment.seconds)` long, which is precisely
29
+ * `motion + freezes`, so the retimed scene comes out the same length as the
30
+ * scene the composition claims and the whole video keeps its duration to the
31
+ * frame. Measured on the demo deck: all twelve narrated scenes satisfy
32
+ * `D == lastHold + spoken` exactly.
33
+ *
34
+ * `assertFits` is the loud failure the brief asks for. If a scene's speech does
35
+ * not fit inside its window we stop, because the alternatives are truncating a
36
+ * sentence or stretching the video, and both are silent lies.
37
+ *
38
+ * WHY THIS LIVES HERE AND NOT IN `layout()`. `layout` is the only function that
39
+ * knows a scene's absolute start and its holds in the same breath, and that is
40
+ * where this manifest ideally comes from. This workstream does not own
41
+ * src/emit/composition.ts, so instead: scene windows are READ BACK from the
42
+ * composition that was just emitted — authoritative, no arithmetic repeated —
43
+ * and holds are obtained the way `narrate` already obtains them, by asking the
44
+ * emitter to stage the beat and reading `scene.holds`. When the format is
45
+ * navigable the composition also carries the slideshow island, and
46
+ * `assertHoldsAgree` cross-checks the derived holds against the fragments the
47
+ * emitter itself wrote. If those two ever disagree, this throws rather than
48
+ * rendering a deck whose audio is aimed at the wrong frames.
49
+ */
50
+ import type { z } from "zod";
51
+ import { type Cue } from "../deck/subtitles.js";
52
+ import { type DeckNarration } from "../emit/composition.js";
53
+ import type { Beat, Format, Source, Storyboard, segmentSchema } from "../types.js";
54
+ type Segment = z.infer<typeof segmentSchema>;
55
+ /** Written beside `index.html` by `build`; read by `render`. */
56
+ export declare const TIMING_FILE = "timing.json";
57
+ export interface TimedScene {
58
+ /** Composition id — `s1`, `s2`. */
59
+ id: string;
60
+ /** Absolute seconds. Identical on the composition and output timelines. */
61
+ start: number;
62
+ duration: number;
63
+ /** Absolute composition seconds: sorted, deduplicated, one per stop. */
64
+ holds: number[];
65
+ /**
66
+ * Scene-relative seconds at which this scene may start speaking — when its
67
+ * headline has landed. Read from the same emit the holds come from, so the two
68
+ * cannot disagree. See `openSeconds`.
69
+ */
70
+ open: number;
71
+ }
72
+ export interface TimedSegment {
73
+ /** `<scene>.<stop>` — stable, and unique because a stop speaks once. */
74
+ id: string;
75
+ scene: string;
76
+ stop: number;
77
+ /** mp3 filename, relative to `audioDir`. */
78
+ audio: string;
79
+ /** Absolute COMPOSITION seconds of the reveal this segment speaks over. */
80
+ hold: number;
81
+ /** Absolute OUTPUT seconds where the audio starts, once the freezes are in. */
82
+ start: number;
83
+ /** Measured length of the mp3. */
84
+ duration: number;
85
+ /** Seconds from the start of this segment's own audio. */
86
+ cues: Cue[];
87
+ }
88
+ /**
89
+ * The timing manifest. Small on purpose: everything a renderer needs and
90
+ * nothing it could derive differently from the deck it is pointed at.
91
+ */
92
+ export interface Timing {
93
+ version: 1;
94
+ width: number;
95
+ height: number;
96
+ /** Composition seconds. The rendered video is exactly this long. */
97
+ duration: number;
98
+ /** BCP-47 tag of the deck's copy. Picks the burn-in font; see `burnStyle`. */
99
+ lang: string;
100
+ /** Directory holding the mp3s, relative to the deck. `""` when silent. */
101
+ audioDir: string;
102
+ voice: string;
103
+ scenes: TimedScene[];
104
+ segments: TimedSegment[];
105
+ }
106
+ /**
107
+ * The scene windows, out of the composition itself.
108
+ *
109
+ * The attribute set `sceneHtml` writes is exact and one-per-line, which is what
110
+ * makes this honest rather than a guess: every scene div carries
111
+ * `data-composition-id`, `data-start` and `data-duration` in that order, and
112
+ * nothing else in the document does. Reading them back means the accumulated
113
+ * `beatSeconds` arithmetic is never performed twice — this is the emitter's own
114
+ * answer, whatever it decided.
115
+ *
116
+ * `data-duration` is the scene's CLIP, and since the handoff work it is longer
117
+ * than the scene's SLIDE by `HANDOFF_SECONDS`: the outgoing scene stays painted
118
+ * into the next one's window so the deck does not cut to background between
119
+ * them (src/emit/camera.ts). Everything here — where a sentence starts, how many
120
+ * frames a piece is — is measured in slides, and a slide ends where the next one
121
+ * begins. Taking the clip instead makes the frame plan longer than the capture
122
+ * by one handoff per scene, which `render` refuses outright rather than
123
+ * silently mistiming; the last scene has no successor and no extension, so its
124
+ * own number is already the slide.
125
+ */
126
+ export declare function readSceneWindows(html: string): TimedScene[];
127
+ /** The root's `data-duration` — the whole deck, in seconds. */
128
+ export declare function readDuration(html: string): number;
129
+ /**
130
+ * The island's fragments, keyed by scene, or null when the format is linear and
131
+ * carries no island. Only ever used to CHECK the holds, never to supply them —
132
+ * a video format has no island and must take the same path.
133
+ */
134
+ export declare function readFragments(html: string): Record<string, number[]> | null;
135
+ /**
136
+ * The derived holds must be the ones the emitter wrote into the island.
137
+ *
138
+ * This is the check that makes reading the staging back out of `emitScene`
139
+ * safe. It can only run for a navigable format — a linear one has no island —
140
+ * but the code path either side of it is identical, so a divergence introduced
141
+ * by an emitter change is caught on the deck format and never reaches a video.
142
+ */
143
+ export declare function assertHoldsAgree(scenes: TimedScene[], fragments: Record<string, number[]>): void;
144
+ /**
145
+ * Lay the segments out on the output timeline.
146
+ *
147
+ * Per scene, walking the stops in order: the video plays to the hold, then
148
+ * freezes for the whole of that stop's audio. So a segment's output start is
149
+ * its hold plus every freeze already inserted in the same scene, and the
150
+ * scene's own start needs no adjustment at all — the freezes it adds are
151
+ * exactly the tail it drops, which is why `start` means the same thing on both
152
+ * timelines and why the video keeps its length.
153
+ *
154
+ * A segment whose `stop` is past the end of the stop list is clamped onto the
155
+ * last one. That only happens when a deck is narrated for one format and built
156
+ * for another whose staging differs; speaking it late over the final state
157
+ * beats dropping the sentence on the floor.
158
+ */
159
+ export declare function place(scenes: TimedScene[], spoken: Record<string, Segment[]>): TimedSegment[];
160
+ /**
161
+ * Every scene's speech fits inside the window the composition gave it.
162
+ *
163
+ * `beatSeconds` already made a narrated scene `max(authored, lastHold + spoken)`
164
+ * long, so this holds by construction today — which is exactly why it is worth
165
+ * asserting. The failure it guards against is a deck built from a storyboard
166
+ * whose narration was re-cut after the fact, or a `--speed` that shrank the
167
+ * holds without shrinking the speech. The brief's rule: fail loudly rather than
168
+ * truncate, and never stretch the video to fit.
169
+ */
170
+ export declare function assertFits(scenes: TimedScene[], segments: TimedSegment[]): void;
171
+ export interface TimingInput {
172
+ storyboard: Storyboard;
173
+ source: Source;
174
+ format: Format;
175
+ /** `prefs.animationSpeed`. Scales holds exactly as `layout` scales them. */
176
+ speed: number;
177
+ /** The composition `emitDeck` just returned. Read, never re-derived. */
178
+ composition: string;
179
+ /**
180
+ * The beats that composition actually drew, in order — `emitDeck`'s `cut.kept`.
181
+ *
182
+ * Absent, the list is re-derived with the format's flat weight threshold, which
183
+ * is what this did before `planCut` could also cut a deck to the format's
184
+ * LENGTH. That fallback is exact for an unbudgeted format and wrong for a
185
+ * budgeted one, so it fails immediately below rather than mis-pairing scenes
186
+ * with beats: a manifest that indexes audio by the wrong scene puts every
187
+ * sentence on the wrong picture, which no gate here can see.
188
+ */
189
+ beats?: readonly Beat[];
190
+ narration?: DeckNarration;
191
+ theme?: string;
192
+ }
193
+ /**
194
+ * One pass over the built deck, producing the manifest `render` consumes.
195
+ *
196
+ * The beat filter and the scene ids repeat `layout`'s two rules — a scene id is
197
+ * a position over the beats a format KEPT — and the scene count is asserted
198
+ * against the composition so a drift in either is a failure and not a
199
+ * misalignment.
200
+ */
201
+ export declare function planTiming(input: TimingInput): Timing;
202
+ /**
203
+ * One stretch of the output video: `motion` frames copied from the source
204
+ * starting at `from`, then `freeze` copies of the last of them.
205
+ */
206
+ export interface Piece {
207
+ /** Source frame index, inclusive. */
208
+ from: number;
209
+ /** Source frames played. Always at least 1. */
210
+ motion: number;
211
+ /** Frames of the last source frame, cloned. 0 outside a stop. */
212
+ freeze: number;
213
+ }
214
+ export interface PlacedAudio {
215
+ id: string;
216
+ audio: string;
217
+ /** Milliseconds from the start of the output. What ffmpeg's `adelay` takes. */
218
+ delayMs: number;
219
+ startFrame: number;
220
+ }
221
+ export interface FramePlan {
222
+ fps: number;
223
+ /** Output frames. Equal to the source's, by construction. */
224
+ frames: number;
225
+ pieces: Piece[];
226
+ audio: PlacedAudio[];
227
+ /** Absolute OUTPUT seconds, already split and wrapped for a screen. */
228
+ cues: Cue[];
229
+ }
230
+ /**
231
+ * The manifest, quantised to whole frames.
232
+ *
233
+ * Everything above is seconds, because seconds are what the composition and the
234
+ * mp3s are measured in. ffmpeg cuts frames, and a trim expressed in seconds
235
+ * lands wherever the nearest frame boundary happens to be — thirty-seven of
236
+ * those rounding independently is a video that ends up a fraction of a second
237
+ * away from its audio. So the cuts are computed as frame indices once, and the
238
+ * audio delays are computed from the same indices, which keeps the two locked
239
+ * together whatever the frame rate is.
240
+ *
241
+ * The tail piece is what makes a scene come out exactly as long as the
242
+ * composition says: it absorbs whatever the motion and the freezes did not use,
243
+ * taken from the static stretch after the last hold. Where narration exactly
244
+ * fills the scene — the ordinary case, since `beatSeconds` sized it that way —
245
+ * the tail is zero and the whole dead tail is dropped.
246
+ */
247
+ export declare function framePlan(timing: Timing, fps: number): FramePlan;
248
+ /**
249
+ * A cue's character budget, scaled for the script it is written in.
250
+ *
251
+ * MEASURED at 40px bold in the caption band, against a usable width of 994px:
252
+ * Latin caption prose advances 0.485em per character, Hangul 0.80em. The 84 of
253
+ * `CUE_MAX_CHARS` is two lines of 42 at the LATIN number, and 42 Hangul
254
+ * characters is 1,346px — half as much again as the band can hold. So a Korean
255
+ * cue at the cap soft-wrapped to a THIRD line: measured at 64 characters the
256
+ * band grew from 128px to 192px and covered the slide, while 60 characters
257
+ * still came out on two. Korean is a first-class language for this product, so
258
+ * the budget is a function of the text rather than a Latin constant.
259
+ *
260
+ * Scaling the budget rather than the font keeps the same headroom `burnStyle`
261
+ * measured — the type stays 40px, which invariant 5 requires, and the cue gets
262
+ * shorter instead.
263
+ */
264
+ export declare function cueMax(text: string, max?: number): number;
265
+ /**
266
+ * A cue on two lines at most.
267
+ *
268
+ * `splitCue` has already capped the cue at its script's budget, which is two
269
+ * lines of broadcast width; this only decides WHERE the break falls, so the two
270
+ * lines come out even instead of one long and one orphaned word. Left to itself
271
+ * the layout would wrap at whatever the box width happened to be, and a
272
+ * burned-in caption that reflows between formats is a caption you cannot
273
+ * art-direct.
274
+ *
275
+ * The default budget is derived from the text for the same reason `cueMax` is:
276
+ * half of 84 is a Latin line, and half of a Korean cue is not.
277
+ */
278
+ export declare function wrap(text: string, max?: number): string;
279
+ /** `01:02:03,456` — SubRip's clock, comma decimal and all. */
280
+ export declare function srtTime(seconds: number): string;
281
+ /**
282
+ * The cues as a SubRip file.
283
+ *
284
+ * Overlaps are clipped rather than dropped: edge-tts has been seen to end a cue
285
+ * a frame after the next one starts, and two captions on screen at once is the
286
+ * one thing a burned-in band cannot survive. A cue left with nothing to show
287
+ * after clipping is dropped instead.
288
+ */
289
+ export declare function toSrt(cues: readonly Cue[]): string;
290
+ export {};
@@ -0,0 +1,11 @@
1
+ export interface JobError {
2
+ message: string;
3
+ hint: string;
4
+ }
5
+ /**
6
+ * Turn anything thrown into the `{ message, hint }` the API promises.
7
+ *
8
+ * `UploadError` already carries its own hint, because the thing that knows a
9
+ * zip has no markdown in it is the code that looked.
10
+ */
11
+ export declare function explain(err: unknown): JobError;
@@ -0,0 +1,57 @@
1
+ import { type Server } from "node:http";
2
+ import { type PipelineInput } from "./pipeline.js";
3
+ import { type JobHandle, type JobResult, Queue } from "./queue.js";
4
+ export interface ServeOptions {
5
+ port: number;
6
+ host: string;
7
+ /** Root under which every job gets its own directory. */
8
+ work: string;
9
+ maxUploadBytes: number;
10
+ maxQueued: number;
11
+ ttlMs: number;
12
+ /** Jobs one IP may submit per hour. The expensive verb gets its own budget. */
13
+ jobsPerHour: number;
14
+ requestsPerMinute: number;
15
+ fetchRemoteFigures: boolean;
16
+ /** Serve decks into a CSP sandbox. On unless deliberately turned off. */
17
+ sandboxDecks: boolean;
18
+ /** Remove a finished job's directory. Injected so tests need no filesystem. */
19
+ removeDir: (dir: string) => void;
20
+ log: (line: string) => void;
21
+ /**
22
+ * The work a job does. Defaults to the real pipeline; substituted in tests so
23
+ * that exercising the routes never spawns a Codex or spends anyone's quota.
24
+ * Same seam, and the same reason, as `Runner` in src/plan/codex.ts.
25
+ */
26
+ run?: (job: JobHandle, input: PipelineInput) => Promise<JobResult>;
27
+ }
28
+ export declare function createDeckServer(opts: ServeOptions): {
29
+ server: Server;
30
+ queue: Queue;
31
+ };
32
+ /**
33
+ * A URL path, turned into a relative path or refused.
34
+ *
35
+ * Same rule as `safeEntryPath`, and for the same reason: percent-decoding is
36
+ * what turns `%2e%2e%2f` back into `../`, so the decode has to happen BEFORE the
37
+ * segments are judged, not after they are joined.
38
+ */
39
+ export declare function safeUrlPath(rest: string): string | null;
40
+ /** One byte range, the only form a browser sends for media. */
41
+ export declare function parseRange(header: string | undefined, size: number): {
42
+ start: number;
43
+ end: number;
44
+ } | "unsatisfiable" | null;
45
+ /**
46
+ * A fixed-window counter per key. Not a token bucket: the thing being limited is
47
+ * "how many decks may one address ask for in an hour", and an hour is the unit
48
+ * the answer is stated in, so a window is what the message can honestly describe.
49
+ */
50
+ export declare class RateLimiter {
51
+ #private;
52
+ private readonly limit;
53
+ private readonly windowMs;
54
+ private readonly now;
55
+ constructor(limit: number, windowMs: number, now?: () => number);
56
+ take(key: string): boolean;
57
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The form fields, turned into the two objects the pipeline actually runs on: a
3
+ * `Format` (canvas and budget) and a `Prefs` (what is said and how it sounds).
4
+ *
5
+ * Every field is optional and every one has a server-side default, so the
6
+ * smallest legal request is a file and nothing else. Validation is delegated to
7
+ * `prefsSchema` wherever the schema already has an opinion — there is one place
8
+ * that decides what a legal tone is, and it is not this file.
9
+ *
10
+ * `loadPrefs` is deliberately NOT used. It walks up from a working directory
11
+ * looking for `decksmith.config.json`, which on a server means a file somewhere
12
+ * above the job directory silently changing what a stranger's upload produces.
13
+ * A request states its own preferences or takes the schema's.
14
+ */
15
+ import { type Format, type Prefs } from "../index.js";
16
+ /**
17
+ * The ONE canvas rule this server adds to the library's.
18
+ *
19
+ * Everything else — whole numbers, `MIN_EDGE`, `MAX_EDGE`, `MAX_ASPECT` — is
20
+ * `canvasProblem`'s, and is derived there from the layout and from Chrome's
21
+ * texture ceiling. This file used to restate the whole question as
22
+ * `MIN_SIDE = 320 … MAX_SIDE = 2560`, which meant three files held three
23
+ * different opinions about what a canvas may be: src/types.ts said 64–16384,
24
+ * this said 320–2560, and src/server/ui.ts's number inputs said 240–7680. The
25
+ * UI's own maximum was refused by the server it posts to — 3840×2160 came back
26
+ * 400 — so the picker offered sizes that could not be built.
27
+ *
28
+ * What survives is the part the library cannot know: this process holds whole
29
+ * frames of width×height×4 bytes in flight during capture, on a box shared with
30
+ * every other job. That is a deployment limit, not a layout one, so it belongs
31
+ * here — and it is published in `catalog()` so the page can enforce the same
32
+ * number rather than guess at one.
33
+ *
34
+ * With `MAX_ASPECT` also in force this implies a longest edge of about 5650px
35
+ * (`MAX_SIDE` below), which is why no separate edge cap is stated: a second
36
+ * number would only be a way to disagree with this one.
37
+ */
38
+ export declare const MAX_PIXELS = 4000000;
39
+ /**
40
+ * The longest edge that can actually be BUILT, which is not quite the longest
41
+ * edge that satisfies the two limits above.
42
+ *
43
+ * `sqrt(MAX_PIXELS × MAX_ASPECT)` is 5656.85, so 5656 looks like the answer —
44
+ * and it is unbuildable. At 5656 wide the aspect limit needs a height of at
45
+ * least 707 and the pixel limit allows at most 707.2, so 707 is the only height
46
+ * that fits; 707 is odd, `even()` rounds it to 706, and 5656/706 is 8.01:1,
47
+ * which is refused. An advertised maximum that returns 400 at every height is
48
+ * exactly the kind of number this reconciliation exists to delete.
49
+ *
50
+ * Rounding down to a multiple of 16 fixes it by construction: it makes
51
+ * `MAX_SIDE / MAX_ASPECT` an even integer, so the extreme canvas is exactly
52
+ * 8:1 with both sides even, and `MAX_SIDE² / MAX_ASPECT ≤ MAX_PIXELS` follows
53
+ * from the same square root. 5648×706 = 3.99 megapixels, and it builds.
54
+ * Asserted in test/server.test.ts rather than left to this comment.
55
+ */
56
+ export declare const MAX_SIDE: number;
57
+ export interface JobOptions {
58
+ format: Format;
59
+ /** The preset the format came from, for the log line and the job view. */
60
+ formatId: string;
61
+ prefs: Prefs;
62
+ narrate: boolean;
63
+ video: boolean;
64
+ /**
65
+ * Whether the REQUEST said so, as opposed to the schema defaulting.
66
+ *
67
+ * `prefs` comes back fully populated, so a theme sitting at "ink" is
68
+ * indistinguishable from one nobody mentioned — and the two must behave
69
+ * differently: the storyboard records the theme it was planned under and the
70
+ * document knows what language it is in, and both should beat a default and
71
+ * lose to anything a person chose. Same rule `stated()` enforces in src/cli.ts.
72
+ */
73
+ stated: {
74
+ theme: boolean;
75
+ lang: boolean;
76
+ };
77
+ /** Anything we corrected rather than refused. Surfaced on the finished job. */
78
+ warnings: string[];
79
+ }
80
+ /** `GET /api/formats`: what the picker needs to draw itself. */
81
+ export declare function formatCatalog(): {
82
+ id: string;
83
+ width: number;
84
+ height: number;
85
+ navigable: boolean;
86
+ maxSeconds: number | null;
87
+ }[];
88
+ /** Everything the picker needs, in one payload, so the UI makes no assumptions. */
89
+ export declare function catalog(): Record<string, unknown>;
90
+ export declare function parseOptions(fields: Record<string, string>): JobOptions;
@@ -0,0 +1,21 @@
1
+ import type { JobOptions } from "./options.js";
2
+ import type { JobHandle, JobResult, Stage } from "./queue.js";
3
+ import { type Upload } from "./upload.js";
4
+ export interface PipelineInput {
5
+ upload: Upload;
6
+ options: JobOptions;
7
+ /**
8
+ * Whether a figure named by an http(s) URL may be downloaded.
9
+ *
10
+ * OFF by default, and that is a security decision, not a performance one. The
11
+ * document is a stranger's; `![](http://169.254.169.254/latest/meta-data/)` is
12
+ * a request this process would make from inside the network it runs in, and a
13
+ * hostname allowlist does not close it because DNS can answer differently the
14
+ * second time. Off, the figure is dropped and named in the warnings. On (the
15
+ * owner's own box, own papers), it is fetched with a count and a timeout.
16
+ */
17
+ fetchRemoteFigures: boolean;
18
+ }
19
+ /** Which rows the step list should have, decided before anything runs. */
20
+ export declare function stagesFor(options: JobOptions): Stage[];
21
+ export declare function runPipeline(job: JobHandle, input: PipelineInput): Promise<JobResult>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * One job at a time, and a line for the rest.
3
+ *
4
+ * WHY ONE. A deck is minutes of work, not milliseconds: planning spawns Codex
5
+ * for about a minute, rendering drives a headless Chrome and ffmpeg for two more
6
+ * on a four-minute video, and capture holds whole frames in memory. This machine
7
+ * has already lost a Chrome to memory pressure at frame 5547 of a single render.
8
+ * Two concurrent renders do not halve the wall clock, they double the peak and
9
+ * turn a slow job into a killed one — so concurrency is 1, the queue is short and
10
+ * bounded, and a waiting caller is told its place rather than left on a spinner.
11
+ *
12
+ * This file runs no pipeline and touches no filesystem. The work is injected as
13
+ * a function, which is what makes the state machine testable without a network,
14
+ * a browser, or a Codex.
15
+ */
16
+ import { type JobError } from "./errors.js";
17
+ export type JobState = "queued" | "running" | "done" | "error";
18
+ export type Stage = "ingest" | "plan" | "narrate" | "build" | "render";
19
+ export type StepState = "pending" | "running" | "done" | "skipped" | "error";
20
+ export interface StepView {
21
+ name: Stage;
22
+ state: StepState;
23
+ /** Epoch ms, absent until the step starts. */
24
+ startedAt?: number;
25
+ /** Wall clock of the step, absent until it ends. */
26
+ ms?: number;
27
+ detail?: string;
28
+ }
29
+ export interface JobResult {
30
+ deckUrl: string;
31
+ videoUrl?: string;
32
+ srtUrl?: string;
33
+ packUrl?: string;
34
+ slides: number;
35
+ /** Seconds of finished deck — measured from the render when there was one. */
36
+ duration: number;
37
+ warnings: string[];
38
+ }
39
+ export interface JobView {
40
+ id: string;
41
+ state: JobState;
42
+ /** The stage currently running, or the one it stopped at. */
43
+ stage?: Stage;
44
+ steps: StepView[];
45
+ log: string[];
46
+ error?: JobError;
47
+ result?: JobResult;
48
+ /** 1 = next to start. Absent once the job is running. */
49
+ queuePosition?: number;
50
+ createdAt: number;
51
+ /** Wall clock since submission, so a UI can say "3m12s" without its own timer. */
52
+ ms: number;
53
+ }
54
+ /** What the injected work is handed to report itself. */
55
+ export interface JobHandle {
56
+ readonly id: string;
57
+ readonly dir: string;
58
+ begin(stage: Stage, detail?: string): void;
59
+ done(stage: Stage, detail?: string): void;
60
+ skip(stage: Stage, why: string): void;
61
+ log(line: string): void;
62
+ }
63
+ export interface QueueOptions {
64
+ /** Refuse past this many WAITING jobs. The running one is not counted. */
65
+ maxQueued?: number;
66
+ /** How long a finished job's files and record survive. */
67
+ ttlMs?: number;
68
+ /** Keep the tail of the log; a render writes a line per few frames. */
69
+ maxLog?: number;
70
+ /** Injected in tests so the clock is not the wall's. */
71
+ now?: () => number;
72
+ /** Called by `sweep` for each expired job, to delete its directory. */
73
+ onExpire?: (id: string, dir: string) => void;
74
+ }
75
+ export declare class QueueFullError extends Error {
76
+ readonly hint: string;
77
+ readonly status = 503;
78
+ constructor(max: number);
79
+ }
80
+ export declare class Queue {
81
+ #private;
82
+ constructor(opts?: QueueOptions);
83
+ get depth(): number;
84
+ get running(): string | undefined;
85
+ /**
86
+ * Take a job, or refuse. Refusing is the point: an unbounded queue on a
87
+ * one-at-a-time worker is a promise the server cannot keep, and a caller told
88
+ * "position 400" has been lied to more politely than one told "full".
89
+ */
90
+ submit(input: {
91
+ id: string;
92
+ dir: string;
93
+ stages: Stage[];
94
+ run: (h: JobHandle) => Promise<JobResult>;
95
+ }): JobView;
96
+ view(id: string): JobView | undefined;
97
+ /** Subscribe to changes for one job. Returns the unsubscribe. */
98
+ watch(id: string, fn: (v: JobView) => void): () => void;
99
+ /**
100
+ * Drop every job whose time is up and report their ids, so the caller can
101
+ * delete the directories. A running job is never swept, however old — the TTL
102
+ * measures how long an ANSWER is kept, not how long work may take.
103
+ */
104
+ sweep(): string[];
105
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The page, as one self-contained HTML document.
3
+ *
4
+ * A function rather than a constant so the interpolated tables above are read
5
+ * once per call and a server that hot-reloads a theme picks the change up
6
+ * without a module cache dance. It is a few hundred microseconds of string
7
+ * concatenation against a request that will take two minutes to satisfy.
8
+ */
9
+ export declare function uiPage(): string;