@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,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; `` 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;
|