@jokerized/decksmith 0.1.3 → 0.2.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.
@@ -0,0 +1,108 @@
1
+ import { type Runner } from "../plan/codex.js";
2
+ import type { ImagesPrefs } from "../types.js";
3
+ export type ImageAspect = "landscape" | "square" | "portrait";
4
+ export interface ImageRequest {
5
+ /** The scene. Never text, labels, numbers or charts — nothing in a picture can be read or checked. */
6
+ prompt: string;
7
+ /** `images.style`, folded into every prompt. */
8
+ style: string;
9
+ aspect: ImageAspect;
10
+ /**
11
+ * `images.model`, for the separate backend only; the Codex and tool rungs
12
+ * ignore it. Carried on the request rather than baked into the provider
13
+ * because the provider is resolved from the environment before any
14
+ * preferences are known, and a `--image-model` that changed only the cache
15
+ * key would be a flag that does nothing.
16
+ */
17
+ model?: string;
18
+ }
19
+ export interface ImageResult {
20
+ bytes: Buffer;
21
+ mime: "image/png" | "image/jpeg" | "image/svg+xml";
22
+ width: number;
23
+ height: number;
24
+ }
25
+ /**
26
+ * One rung. `check` is separate from `generate` for the reason `SpeechProvider`
27
+ * splits them: a missing key is reported before a picture is asked for, not
28
+ * two minutes into one.
29
+ */
30
+ export interface ImageProvider {
31
+ readonly id: string;
32
+ /** Throws with an actionable sentence. */
33
+ check(): Promise<void>;
34
+ generate(req: ImageRequest): Promise<ImageResult>;
35
+ }
36
+ /** Pixels per aspect — the backend's `size`, and the tool's viewBox. */
37
+ export declare const SIZE: Readonly<Record<ImageAspect, {
38
+ width: number;
39
+ height: number;
40
+ }>>;
41
+ export interface OpenAiImagesOptions {
42
+ apiKey: string;
43
+ /** `POST {baseUrl}/images/generations`. LocalAI and most gateways speak this. */
44
+ baseUrl?: string;
45
+ /** Default when the request names none. */
46
+ model?: string;
47
+ /** Injected by tests. */
48
+ fetch?: typeof fetch;
49
+ }
50
+ /**
51
+ * Any OpenAI-compatible images endpoint.
52
+ *
53
+ * Errors are shaped here rather than passed through: the message is status plus
54
+ * the API's `error.code` (or `type`), never its text and never a URL, so it can
55
+ * go straight into a job log a stranger reads. The key is used exactly once,
56
+ * on the generation request — a `url` in the answer is fetched bare, and only
57
+ * when it sits on the backend's own origin, because following an arbitrary URL
58
+ * with or without the key is how a gateway's answer becomes an open proxy.
59
+ */
60
+ export declare function openaiImages(opts: OpenAiImagesOptions): ImageProvider;
61
+ export interface CodexImagesOptions {
62
+ /** Injected by tests; `runCodex` otherwise. */
63
+ run?: Runner;
64
+ timeoutMs?: number;
65
+ }
66
+ /**
67
+ * The main account, drawing with its own image tool.
68
+ *
69
+ * `codex exec` runs in a scratch directory under `workspace-write`, which
70
+ * `codexCommand` fences to that directory and nothing else, and is asked to
71
+ * leave one PNG there. An account without the tool answers `ok:false` and says
72
+ * why; that reason is the error, verbatim, because it is the one thing the
73
+ * user can act on. Whether non-interactive `codex exec` exposes the tool at all
74
+ * is the hypothesis the design's live run settles — this code is written for
75
+ * either answer.
76
+ */
77
+ export declare function codexImages(opts?: CodexImagesOptions): ImageProvider;
78
+ /**
79
+ * The picture the tool draws for itself: six to ten overlapping shapes in one
80
+ * accent and the ink, seeded from the brief. Not an illustration of the scene —
81
+ * a stable, text-free composition that keeps the slide's layout honest until
82
+ * something better draws it. Same request, same bytes.
83
+ */
84
+ export declare function drawSvg(req: ImageRequest): string;
85
+ /** The size the tool wrote into its own SVG. `imageSize` is raster-only on purpose. */
86
+ export declare function svgSize(bytes: Buffer): {
87
+ width: number;
88
+ height: number;
89
+ };
90
+ /** The last rung. Pure, so it cannot fail, so `illustrate` always finishes. */
91
+ export declare function toolSvg(): ImageProvider;
92
+ /**
93
+ * The backend the environment names, or nothing.
94
+ *
95
+ * Environment only, mirroring `DECKSMITH_TTS`: a key is never a preference,
96
+ * never in a config file and never in a `.deck`. Naming a backend without its
97
+ * key, or naming one this build does not have, throws HERE — at `illustrate`
98
+ * on the CLI, in the server's preflight, in MCP `capabilities` — and nowhere
99
+ * at import, so a broken deployment is a sentence in the log rather than a
100
+ * process that will not start.
101
+ */
102
+ export declare function resolveImageBackend(env?: NodeJS.ProcessEnv): ImageProvider | undefined;
103
+ /**
104
+ * The rungs, in the order `illustrate` tries them, for one preference.
105
+ * `auto` is all three; `codex` skips the backend; `svg` is the tool alone — no
106
+ * network, no spend, and a deck whose every picture is reproducible.
107
+ */
108
+ export declare function imageChain(images: ImagesPrefs, backend?: ImageProvider): ImageProvider[];
@@ -34,7 +34,7 @@ export type { CodexOptions, Runner } from "./plan/codex.js";
34
34
  * not contain. `buildDeck` does not call it — a caller assembling a storyboard
35
35
  * by hand wants this before it spends a render on a dangling ref.
36
36
  */
37
- export { assertInsideResolves, assertRefsResolve } from "./plan/refs.js";
37
+ export { assertInsideResolves, assertRefsResolve, hasIllustrations } from "./plan/refs.js";
38
38
  /** The prompt, so a consumer driving its own model can reproduce our planning. */
39
39
  export { renderSource, systemPrompt } from "./plan/prompt.js";
40
40
  /**
@@ -42,8 +42,22 @@ export { renderSource, systemPrompt } from "./plan/prompt.js";
42
42
  * turned into the two derived ones. Pure arithmetic over preferences, so a
43
43
  * caller can ask what a target costs before spending a plan on it.
44
44
  */
45
- export { COMFORTABLE_CPS, durationPlan, slidesFor, LAST_HOLD_SECONDS, MAX_PLAYBACK, MIN_SENTENCE_CHARS, MOTION_SHARE, p95CueRate, playbackFactor, playbackWarning, SPEAKING_STOPS, SPEECH_CPS, STOPS_PER_BEAT, tempoChain, } from "./plan/duration.js";
45
+ export { COMFORTABLE_CPS, durationPlan, slidesFor, LAST_HOLD_SECONDS, MAX_PLAYBACK, MIN_SENTENCE_CHARS, MOTION_SHARE, p95CueRate, playbackFactor, playbackRefusal, playbackWarning, SPEAKING_STOPS, SPEECH_CPS, STOPS_PER_BEAT, tempoChain, } from "./plan/duration.js";
46
46
  export type { DurationPlan } from "./plan/duration.js";
47
+ /**
48
+ * Pictures for the beats that asked for one. `illustrate` is a plan-time step
49
+ * like `narrate` — it writes files beside the source and rewrites both
50
+ * documents — so a server runs it between `plan` and `build` and hands the
51
+ * returned objects on; nothing after it knows a figure was generated.
52
+ * `resolveImageBackend` and `imageChain` are the two halves of "where the
53
+ * pictures come from": the environment names a backend, the preference says
54
+ * where the chain starts, and a caller that wants neither injects its own
55
+ * `ImageProvider[]` — which is how a server's tests never spawn Codex.
56
+ */
57
+ export { illustrate } from "./images/illustrate.js";
58
+ export type { Illustrated, IllustrateOpts } from "./images/illustrate.js";
59
+ export { imageChain, resolveImageBackend } from "./images/providers.js";
60
+ export type { ImageAspect, ImageProvider, ImageRequest, ImageResult, } from "./images/providers.js";
47
61
  /**
48
62
  * Text-to-speech over a storyboard. Needs the `edge-tts` binary on PATH, which
49
63
  * is why it is a separate call and not a `buildDeck` option.
@@ -87,12 +101,17 @@ export type { DeckTheme } from "./emit/themes/index.js";
87
101
  */
88
102
  export { verify } from "./verify/index.js";
89
103
  /**
90
- * The storyboard advisory `plan` prints and `verify` folds in: a headline that
91
- * recites the visual's own labels as a list. Exported because it is pure over a
92
- * storyboard no browser, no built deck so a caller can run it on a plan
93
- * before spending anything on it, which is the only moment it is worth acting on.
94
- */
95
- export { scanHeadlines, scanNarrationLead, scanRepeatedObject } from "./verify/index.js";
104
+ * The storyboard advisories `plan` prints: a headline that recites the visual's
105
+ * own labels as a list, one object drawn twice, a name spoken before it is drawn,
106
+ * a source figure the deck never uses, and a plan that came back under the slide
107
+ * count it was asked for. Exported because they are pure over a storyboard no
108
+ * browser, no built deck — so a caller can run them on a plan before spending
109
+ * anything on it, which is the only moment they are worth acting on. All but
110
+ * `scanBeatCount` also fold into `verify`; that one needs the preferences the
111
+ * deck was asked for, which a built directory does not carry — and
112
+ * `scanUnusedFigures` folds in only where the source was passed alongside.
113
+ */
114
+ export { scanBeatCount, scanHeadlines, scanNarrationLead, scanRepeatedObject, scanUnusedFigures, } from "./verify/index.js";
96
115
  export { check, parseCheckReport, sampleTimes } from "./verify/check.js";
97
116
  export type { CheckOptions } from "./verify/check.js";
98
117
  /**
@@ -15,6 +15,24 @@ export interface Prereq {
15
15
  * probe is therefore advisory — hence the honest `neededFor` naming both stages.
16
16
  */
17
17
  export declare function prereqs(): Promise<Prereq[]>;
18
+ /**
19
+ * Where pictures would come from, asked the same way and for the same reason.
20
+ *
21
+ * Not a `Prereq`, because nothing has to be installed: the last rung is an SVG
22
+ * the tool draws itself, so a request for illustrations never fails for lack of
23
+ * a backend. What CAN be wrong is a backend the environment names and cannot
24
+ * use — DECKSMITH_IMAGES set, the key not — and that is a misconfiguration to
25
+ * report before the plan is paid for, not a stage to skip. `why` is the
26
+ * library's own sentence, which names the variable and never carries the key.
27
+ *
28
+ * `env` is injected in tests, like `probe`: which backend this machine happens
29
+ * to name is not what a test is about.
30
+ */
31
+ export declare function imageBackend(env?: NodeJS.ProcessEnv): {
32
+ backend: string | null;
33
+ ok: boolean;
34
+ why?: string;
35
+ };
18
36
  /** The ones a job with these options actually needs, and which are missing. */
19
37
  export declare function missingFor(found: readonly Prereq[], opts: {
20
38
  narrate: boolean;
@@ -39,6 +39,7 @@ export declare const settingsSchema: z.ZodObject<{
39
39
  low: "low";
40
40
  }>>;
41
41
  video: z.ZodOptional<z.ZodBoolean>;
42
+ images: z.ZodOptional<z.ZodBoolean>;
42
43
  }, z.core.$strip>;
43
44
  export type Settings = z.infer<typeof settingsSchema>;
44
45
  export declare const capabilitiesSchema: z.ZodObject<{}, z.core.$strip>;
@@ -75,6 +76,7 @@ export declare const estimateSchema: z.ZodObject<{
75
76
  low: "low";
76
77
  }>>;
77
78
  video: z.ZodOptional<z.ZodBoolean>;
79
+ images: z.ZodOptional<z.ZodBoolean>;
78
80
  }, z.core.$strip>;
79
81
  }, z.core.$strip>;
80
82
  export declare const createSchema: z.ZodObject<{
@@ -112,6 +114,7 @@ export declare const createSchema: z.ZodObject<{
112
114
  low: "low";
113
115
  }>>;
114
116
  video: z.ZodOptional<z.ZodBoolean>;
117
+ images: z.ZodOptional<z.ZodBoolean>;
115
118
  }, z.core.$strip>>;
116
119
  wait_seconds: z.ZodOptional<z.ZodNumber>;
117
120
  }, z.core.$strip>;
@@ -132,6 +135,13 @@ export interface McpOptions {
132
135
  * probe, so the server itself is unchanged.
133
136
  */
134
137
  probe?: () => Promise<Prereq[]>;
138
+ /**
139
+ * The environment the image backend is resolved from, for `capabilities` and
140
+ * the refusal in `create`. Injected for the reason `probe` is. The pipeline
141
+ * itself still reads `process.env` when its `illustrate` stage runs — in a
142
+ * server the two are the same object, and a test never lets a job get there.
143
+ */
144
+ env?: NodeJS.ProcessEnv;
135
145
  }
136
146
  /**
137
147
  * The four tools, over one queue.
@@ -143,6 +153,11 @@ export interface McpOptions {
143
153
  export declare function deckTools(opts: McpOptions): {
144
154
  /** Formats, themes, every setting's range, and what is actually installed. */
145
155
  capabilities(): Promise<{
156
+ images: {
157
+ backend: string | null;
158
+ ok: boolean;
159
+ why?: string;
160
+ };
146
161
  prerequisites: Prereq[];
147
162
  root: string;
148
163
  work: string;
@@ -155,6 +170,14 @@ export declare function deckTools(opts: McpOptions): {
155
170
  * the author what their settings cost ("60s over 12 slides leaves 85
156
171
  * characters a slide, which is one sentence"), and an MCP that swallowed
157
172
  * them would ship the exact failure this project keeps having.
173
+ *
174
+ * EVERY NUMBER HERE IS AT THE REQUESTED SLIDE COUNT, which is the only one a
175
+ * pre-flight has — there is no plan yet, so there are no beats to strike the
176
+ * budget at. `create` below builds the deck at the count the planner returns
177
+ * (see `durationPlan`'s header), so a plan that comes back short reports
178
+ * different numbers than this did, and `scanBeatCount` says by how much. The
179
+ * field names carry it: `slides` is the request, and everything `_per_slide`
180
+ * is per requested slide.
158
181
  */
159
182
  estimate(input: z.infer<typeof estimateSchema>): {
160
183
  slides: number;
@@ -13,12 +13,34 @@ export interface CodexOptions {
13
13
  /** Swappable so a test can drive the real parse path without spawning anything. */
14
14
  run?: Runner;
15
15
  }
16
- /** What the planner needs from the outside world: a prompt in, a final message out. */
17
- export type Runner = (args: {
16
+ export interface RunnerArgs {
18
17
  prompt: string;
19
18
  schemaPath: string;
20
19
  outPath: string;
21
20
  model?: string;
22
21
  timeoutMs: number;
23
- }) => Promise<void>;
22
+ /** Where the agent runs. Absent means wherever this process is, which planning never cares about. */
23
+ cwd?: string;
24
+ /**
25
+ * `read-only` unless said otherwise. `workspace-write` exists for the one
26
+ * caller that needs a file back — `illustrate`'s Codex rung — and needs a
27
+ * `cwd` to fence the writes to.
28
+ */
29
+ sandbox?: "read-only" | "workspace-write";
30
+ }
31
+ /** What the planner needs from the outside world: a prompt in, a final message out. */
32
+ export type Runner = (args: RunnerArgs) => Promise<void>;
24
33
  export declare function codexPlanner(source: Source, opts?: CodexOptions): Promise<Storyboard>;
34
+ /**
35
+ * The `codex` command line for a run, and the environment it needs.
36
+ *
37
+ * Pure, and exported for that reason: the argv is a contract with a binary
38
+ * nothing in the test suite may spawn, so it is pinned by reading this rather
39
+ * than by running it.
40
+ */
41
+ export declare function codexCommand(args: RunnerArgs): {
42
+ argv: string[];
43
+ env?: NodeJS.ProcessEnv;
44
+ };
45
+ /** The production `Runner`: `codex exec` on PATH, stdin in, a file out. */
46
+ export declare function runCodex(args: RunnerArgs): Promise<void>;
@@ -23,10 +23,27 @@
23
23
  * else, so hitting 60s over 12 slides needs a shorter sentence AND a faster
24
24
  * animation. Neither alone gets there; that is the whole content of this file.
25
25
  *
26
+ * WHICH SLIDE COUNT THE BUDGET IS STRUCK AT. `prefs.slides` is what was ASKED
27
+ * FOR and `beats` is what the planner came back with, and they are routinely not
28
+ * the same number — four of the last five real plans came back short. Every
29
+ * derived number here is `duration / count`, so striking the budget at the
30
+ * request while the deck has fewer beats paces each scene for a slide shorter
31
+ * than the one it is: eight beats against a sixty-second target were paced for
32
+ * five-second beats that are really seven and a half, and the video came in
33
+ * under its target with every gate green and nothing anywhere saying so. So
34
+ * every caller holding a storyboard passes its `beats.length`. The two that
35
+ * cannot are `systemPrompt`, which builds the prompt before a plan exists, and
36
+ * the MCP `estimate`, which is a pre-flight with no plan at all; they get the
37
+ * request, which is the only number they have. The SHORTFALL itself is not this
38
+ * file's to report — `scanBeatCount` in src/verify/index.ts reports it, because
39
+ * a beat count is arithmetic rather than a judgement and a prompt rule alone can
40
+ * always be met cosmetically.
41
+ *
26
42
  * Everything here is pure arithmetic over preferences, so it is testable with no
27
43
  * planner, no TTS and no ffmpeg.
28
44
  */
29
45
  import type { Prefs } from "../prefs.js";
46
+ import type { Source } from "../types.js";
30
47
  /**
31
48
  * Characters of text per second of speech.
32
49
  *
@@ -174,6 +191,17 @@ export declare const FF_BEAT_SECONDS = 8;
174
191
  * for both put 60 seconds at eight slides when the reference is twelve.
175
192
  */
176
193
  export declare const MIN_BEAT_SECONDS = 4;
194
+ /**
195
+ * The longest a slide may sit and still be one thought.
196
+ *
197
+ * Past twenty seconds a beat stops being a slide and becomes a section of a
198
+ * lecture: the demo sits at 20.5s and is already the slowest thing this project
199
+ * ships. It was the bare `20` inside `slidesFor`'s tempo clamp; it is named
200
+ * because that function now uses it twice — once to stop a long target from
201
+ * flattening into a handful of enormous beats, and once as the floor a thin
202
+ * DOCUMENT may shorten the deck to. Both are the same rule.
203
+ */
204
+ export declare const MAX_BEAT_SECONDS = 20;
177
205
  /**
178
206
  * Subtitle rate a short-form viewer will take, as against broadcast television.
179
207
  *
@@ -212,13 +240,66 @@ export declare const SHORT_FORM_CPS = 22;
212
240
  * takes `+10%` and lands at 21.9. Wrong in the OUTPUT, not in a gate — no gate
213
241
  * looks at this, which is why it survived.
214
242
  *
215
- * SCALE-INVARIANCE IS ASSUMED, and is the one thing here not measured: the ratio
216
- * is taken at `+0%` and applied at every step, on the reasoning that speeding the
217
- * voice up shrinks the cue windows and the breaths between them together.
218
- * Measuring it at `+10%` and `+20%` means synthesising the demo again at those
219
- * rates. `test/duration.test.ts` pins the `+0%` end against the artifact.
243
+ * SCALE-INVARIANCE IS MEASURED, and it holds. The ratio is taken at `+0%` and
244
+ * applied at every step, on the reasoning that speeding the voice up shrinks the
245
+ * cue windows and the breaths between them together. `scripts/measure-cue-rate.mjs`
246
+ * re-synthesised all 37 segments at all five steps `+0%` included, in one
247
+ * session, because edge-tts does not repeat itself and a fresh rate judged
248
+ * against a stored baseline measures the drift instead:
249
+ *
250
+ * ```
251
+ * rate meanCps p95 cue p95/mean median/mean
252
+ * +0% 14.509 18.089 1.2467 1.0601
253
+ * +10% 15.752 20.392 1.2945 1.0653
254
+ * +20% 17.148 21.321 1.2434 1.0567
255
+ * +30% 18.540 23.263 1.2548 1.0572
256
+ * +40% 20.205 25.515 1.2628 1.0663
257
+ * ```
258
+ *
259
+ * No trend in rate — so this stays a constant rather than becoming a function of
260
+ * the step. The p95 column wobbles because p95 over 39 cues IS THE SECOND-HIGHEST
261
+ * CUE, one window wide; `median/mean` is flat to within a percent, and it is the
262
+ * honest read on whether the distribution's shape moves. It does not.
263
+ *
264
+ * IT AND `RATE_STEPS` ARE ONE UNIT, and they moved together — 2026-08-02, from
265
+ * the run above. Read that table's `speedup` note before changing either.
266
+ *
267
+ * WHY 1.30 AND NOT 1.2945. It has to sit above EVERY ratio the run observed,
268
+ * because the number it feeds is a ceiling on caption readability and being
269
+ * under it means shipping captions faster than `SHORT_FORM_CPS`. The old 1.28
270
+ * was below the `+10%` reading and got away with it only because the speedups
271
+ * beside it were inflated by more — two errors cancelling, which is not a margin
272
+ * anyone can reason about. Correcting the speedups removed that cover, so this
273
+ * had to rise with them.
274
+ *
275
+ * IT COSTS A STEP, KNOWINGLY. The demo's real p95 cue at `+20%` is 21.321,
276
+ * inside the ceiling of 22 — but this predicts 22.13 for that step and refuses
277
+ * it, so the deck takes `+10%` and speaks slower than the artifact proves it
278
+ * could. It also costs the 60-second case its full sentence: twelve slides at
279
+ * low density plan 66 characters against the 72 of `EXPLAINING_CHARS`, where
280
+ * ~1.26 would plan exactly 72.
281
+ *
282
+ * 1.26 IS NOT AVAILABLE, THOUGH, and it is worth writing down why because the
283
+ * arithmetic above makes it look free. **The floor is 1.2784**, which is this
284
+ * deck's own `DEMO_P95_CUE_CPS / SPEECH_CPS.latin` — 18.409 / 14.4 — and
285
+ * `test/duration.test.ts` fails anything under it, one-sided and on purpose.
286
+ * A constant below the anchor's measured ratio admits a step whose captions are
287
+ * over the ceiling, which is exactly the 1.17 bug this replaced.
288
+ *
289
+ * The five ratios in the table above centre on 1.2604 and tempt you under that
290
+ * floor. They are a DIFFERENT synthesis of the same text: edge-tts does not
291
+ * repeat itself, and the `+0%` re-run came back at 1.2467 where the artifact in
292
+ * `demo/audio` sits at 1.2784. The shipped artifact wins — it is the deck that
293
+ * actually exists. So the honest range is [1.2784, 1.2945], the second number
294
+ * being the highest ratio the re-run saw, and 1.30 clears both.
295
+ *
296
+ * The 60-second sentence is therefore not bought back by tuning this. It is
297
+ * bought with seconds or with slides, which is what the rest of this file has
298
+ * said all along, and 90s plans 102.
299
+ *
300
+ * `test/duration.test.ts` pins the `+0%` end against the artifact.
220
301
  */
221
- export declare const CUE_OVERHEAD = 1.28;
302
+ export declare const CUE_OVERHEAD = 1.3;
222
303
  /** Broadcast subtitle practice. Past this the captions stop being readable. */
223
304
  export declare const COMFORTABLE_CPS = 17;
224
305
  /** How far playback may be sped up before the captions are the problem. */
@@ -261,6 +342,29 @@ export declare const SPEAKING_STOPS: Record<Prefs["narration"]["density"], numbe
261
342
  * `+60%` it is 28.5 and the caption is gone before it is read. `+50%` is
262
343
  * excluded for measuring slower than the step below it.
263
344
  *
345
+ * THE SPEEDUPS BELOW ARE NOT FROM THAT TABLE. They were, and they were too high
346
+ * at every step. The table above times ONE 72-character sentence, and a short
347
+ * sentence carries proportionally more silence at its ends, so dividing its
348
+ * total duration overstates what the prosody rate does to speech itself.
349
+ * Re-measured over the anchor deck's 37 segments and 196 seconds:
350
+ *
351
+ * ```
352
+ * step was is from
353
+ * +10% 1.187 1.086 meanCps 15.752 / 14.509
354
+ * +20% 1.252 1.182 meanCps 17.148 / 14.509
355
+ * +30% 1.394 1.278 meanCps 18.540 / 14.509
356
+ * +40% 1.533 1.393 meanCps 20.205 / 14.509
357
+ * ```
358
+ *
359
+ * `CUE_OVERHEAD` rose from 1.28 to 1.30 in the same commit and for this reason:
360
+ * it was below the cue ratio measured at `+10%` and only safe because these
361
+ * numbers were inflated by more. Neither is a safe edit alone — see the
362
+ * paragraph there. One run of `scripts/measure-cue-rate.mjs` produces both.
363
+ *
364
+ * THE TABLE ABOVE STILL EARNS ITS PLACE, because it is the only measurement of
365
+ * `+50%` and `+60%` anyone has taken, and what it says about them is why this
366
+ * list stops at `+40%`. The re-measurement covered these five steps only.
367
+ *
264
368
  * Latin-measured. A CJK deck gets the same steps, which is a guess of the same
265
369
  * kind `SPEECH_CPS.cjk` already is — replace it the first time one is narrated.
266
370
  */
@@ -285,7 +389,16 @@ export interface DurationPlan {
285
389
  chars?: number;
286
390
  /** Seconds of speech one beat can afford. Absent without a target. */
287
391
  speechSeconds?: number;
288
- /** The per-beat length the target implies. Absent without a target. */
392
+ /**
393
+ * The per-beat length the target implies, at the count the plan was struck at.
394
+ * Absent without a target.
395
+ *
396
+ * A BUDGET, NOT THE ANSWER, and the name collides with the thing that IS the
397
+ * answer. `beatSeconds` in src/emit/composition.ts — `max(authored·speed,
398
+ * lastHold + SETTLE, speechEnd + SETTLE)` — is what actually sizes a scene,
399
+ * and it never sees this number. `speed` is the only value this file sends
400
+ * into the emitted timeline; everything else here is advice to the planner.
401
+ */
289
402
  beatSeconds?: number;
290
403
  /** Said, never fatal: what the target costs, or cannot buy. */
291
404
  warnings: string[];
@@ -300,8 +413,14 @@ export interface DurationPlan {
300
413
  * `duration` set OVERRIDES `animationSpeed`: the target owns the pace, because
301
414
  * the two cannot both be honoured and the target is the one the user stated a
302
415
  * number for. Said in a warning rather than silently.
416
+ *
417
+ * `beats` is the count the budget is struck at, and it defaults to the count
418
+ * that was asked for. Pass the storyboard's own `beats.length` wherever one is
419
+ * in hand — see the header for why, and for the two callers that structurally
420
+ * cannot. The default is not a convenience: it is the right answer for those
421
+ * two, and it is what keeps a run where the planner hit its number identical.
303
422
  */
304
- export declare function durationPlan(prefs: Prefs): DurationPlan;
423
+ export declare function durationPlan(prefs: Prefs, beats?: number): DurationPlan;
305
424
  /**
306
425
  * How many slides a target should have, by TEMPO.
307
426
  *
@@ -328,11 +447,90 @@ export declare function durationPlan(prefs: Prefs): DurationPlan;
328
447
  * 30s -> 8 120s -> 12 600s -> 30
329
448
  * 60s -> 12 300s -> 15
330
449
  *
450
+ * AND HOW MUCH THE DOCUMENT HAS TO SAY, which is the half that was missing. The
451
+ * table above is flat at twelve from 48s to 240s no matter what it is pointed
452
+ * at: a four-page workshop note and a forty-page survey both got twelve beats,
453
+ * because the only input was a clock. Tempo says how long a beat may LAST; it
454
+ * cannot say how many points exist to spend beats on. So `source`, when the
455
+ * caller has one, scales the tempo count by what the document actually contains
456
+ * (`sourcePoints`), bounded by `SUPPLY_RANGE` because the measure is coarse and
457
+ * its authority should be too.
458
+ *
459
+ * 60s + a thin note -> 6 240s + a thin note -> 12
460
+ * 60s + a full paper -> 15 240s + a full paper -> 17
461
+ *
462
+ * The clock still wins at both ends. Sixty seconds cannot hold seventeen beats
463
+ * at `MIN_BEAT_SECONDS` apiece, so a rich document against a short target spends
464
+ * its extra points on nothing; and past four minutes `MAX_BEAT_SECONDS` holds
465
+ * the count up, so a thin note asked for a long video does not become six
466
+ * forty-second slides. Only where the clock leaves room does the document move
467
+ * the number — which is most of the range people actually ask for.
468
+ *
469
+ * WHO CAN PASS A SOURCE. The CLI's `plan` verb reads `source.json` before it
470
+ * resolves preferences, so it can and does. `parseOptions` (src/server/options.ts)
471
+ * derives the count while the upload is still a form, before anything is
472
+ * ingested, and the MCP `estimate` is a pre-flight with no document at all;
473
+ * both get the tempo number, which is what they got before. Omitting the
474
+ * argument is therefore not a degraded path, it is the old behaviour, byte for
475
+ * byte.
476
+ *
331
477
  * This is the DEFAULT, never an override. `slides` is one of the three knobs the
332
478
  * owner asked to hold — "user can give you the number of slides they want in the
333
- * video with duration of their choice" — so an explicit count is obeyed.
479
+ * video with duration of their choice" — so an explicit count is obeyed, which
480
+ * is `loadPrefs`'s business: it calls this only when nobody named a number.
481
+ */
482
+ export declare function slidesFor(prefs: Prefs, source?: Source): number;
483
+ /**
484
+ * Characters of section prose one beat is worth.
485
+ *
486
+ * A GUESS, and marked one the way `SPEECH_CPS.cjk` is: no full-length document
487
+ * is stored in this repository to measure against — `demo/source.json` is a
488
+ * 231-character stub whose twelve beats were written by hand, and the fixture
489
+ * papers are 1.9 KB each. What matters is this number's RATIO to
490
+ * `REFERENCE_POINTS`, and the two are set together so an eight-section
491
+ * conference paper — around 30k characters, five figures, two tables, four
492
+ * equations — comes out above the reference rather than at it. Replace both with
493
+ * a measurement the first time a corpus of real sources exists.
334
494
  */
335
- export declare function slidesFor(prefs: Prefs): number;
495
+ export declare const PROSE_CHARS_PER_BEAT = 1800;
496
+ /**
497
+ * The supply a document needs to earn the tempo's own beat count.
498
+ *
499
+ * Twenty points is roughly a solid conference paper minus its trimmings. Below
500
+ * it the deck shortens, above it the deck lengthens, and `SUPPLY_RANGE` decides
501
+ * how far either can go.
502
+ */
503
+ export declare const REFERENCE_POINTS = 20;
504
+ /**
505
+ * How far the document may move the tempo's count, as a multiplier.
506
+ *
507
+ * Bounded rather than open because `sourcePoints` is a proxy and a proxy should
508
+ * not be trusted past the range where it is obviously right. Half is the
509
+ * shortest a deck of a real document should get before the answer is "this is
510
+ * not enough source"; one and a half keeps a rich paper under the twenty-second
511
+ * beat that makes a deck a lecture. A survey with a hundred points is not worth
512
+ * a hundred beats, and the clamp is what says so.
513
+ */
514
+ export declare const SUPPLY_RANGE: {
515
+ readonly min: 0.5;
516
+ readonly max: 1.5;
517
+ };
518
+ /**
519
+ * How many distinct points a document offers a deck.
520
+ *
521
+ * Two supplies, added, because they are genuinely different things to make a
522
+ * beat out of: PROSE, which carries the argument, and EXHIBITS — the figures,
523
+ * tables and equations the authors made on purpose because a sentence was not
524
+ * enough. RULE 2 in the prompt tells the planner every figure should earn a
525
+ * beat, so a document with eight of them is asking for a longer deck than one
526
+ * with none, and this is where that is counted rather than hoped for.
527
+ *
528
+ * Headings are deliberately NOT counted. A section boundary is a formatting
529
+ * decision — one author writes six headings over 20k characters and another
530
+ * writes twenty-four over the same prose — so counting them would measure the
531
+ * template rather than the document.
532
+ */
533
+ export declare function sourcePoints(source: Source): number;
336
534
  /**
337
535
  * How fast this deck speaks.
338
536
  *
@@ -383,12 +581,48 @@ export declare function p95CueRate(cues: readonly {
383
581
  text: string;
384
582
  }[]): number;
385
583
  /**
386
- * Whether this deck's own captions survive being sped up this much.
387
- *
388
- * Computed from the deck's MEASURED p95 cue rate against broadcast practice, not
389
- * from a hardcoded ceiling: a deck whose captions already run at 18 cps has no
390
- * headroom at all, and one written in short lines has plenty. Warns, never
391
- * refuses how readable a caption has to be is the user's call, and they can
392
- * only make it if they are told the number.
584
+ * What is wrong with speeding this deck up this much — everything that is.
585
+ *
586
+ * TWO CEILINGS, AND THEY ARE NOT THE SAME KIND OF THING. The caption one is
587
+ * computed from the deck's MEASURED p95 cue rate against broadcast practice
588
+ * rather than from a constant: a deck whose captions already run at 18 cps has
589
+ * no headroom at all, one written in short lines has plenty. That one stays
590
+ * pure advice how readable a caption has to be is the user's call, and they
591
+ * can only make it if they are told the number. `MAX_PLAYBACK` is the other,
592
+ * and `playbackRefusal` below turns it into a refusal, because time-stretched
593
+ * audio is not a matter of taste.
594
+ *
595
+ * BOTH CLAUSES, NOT THE FIRST ONE. This used to `return` out of the caption
596
+ * branch, which meant a dense deck — every deck this tool plans is dense, by
597
+ * construction — could only ever be told about the ceiling that is advisory,
598
+ * and never about the one that is enforced. The measured shape: 2.14× on a
599
+ * 19.6 cps deck printed "readable captions cap out near 0.87×" and suppressed
600
+ * "past the 1.25× that reads comfortably", so the only line naming an
601
+ * enforceable limit was the one the user did not get.
393
602
  */
394
603
  export declare function playbackWarning(factor: number, p95: number): string | undefined;
604
+ /**
605
+ * Why this target cannot be reached from this deck, when it cannot.
606
+ *
607
+ * `MAX_PLAYBACK` IS THE ONE THAT CAN BE ENFORCED. The caption ceiling cannot
608
+ * be, and the arithmetic is not close: `COMFORTABLE_CPS / DEMO_P95_CUE_CPS` is
609
+ * 17/18.409 = 0.92 on this project's own anchor deck, so a caption-derived
610
+ * limit is already breached at 1× and would refuse every speed-up there is —
611
+ * `--duration` would be dead on arrival. Every deck DeckSmith plans is denser
612
+ * than 17 cps by construction; see `CUE_OVERHEAD`. So the captions warn and
613
+ * the playback factor refuses, and `test/duration.test.ts` pins the reason.
614
+ *
615
+ * REFUSING IS NOT NEW POLICY, it is the policy the planner already keeps.
616
+ * `durationPlan` sizes a deck so the residual gap fits inside `MAX_PLAYBACK`,
617
+ * and `test/duration.test.ts` asserts exactly that at 60s, 120s, 180s and
618
+ * 240s. A 2.14× request is not a tight fit that slipped — it is 71% past a
619
+ * bound the rest of the file treats as arithmetic. What was shipping instead
620
+ * was a video nobody could follow, announced by a mid-render log line that 40
621
+ * lines of capture progress scrolled away.
622
+ *
623
+ * Both remedies are named because the honest one is not the flag: the length
624
+ * of a deck is decided at plan time by how much it says, so the fix is fewer
625
+ * words or more seconds, and `--allow-fast-playback` is for the person who has
626
+ * looked at the alternative and wants the fast video anyway.
627
+ */
628
+ export declare function playbackRefusal(actualSeconds: number, targetSeconds: number, p95: number): string | undefined;
@@ -43,5 +43,11 @@ export declare function systemPrompt(prefs: Prefs): string;
43
43
  * planner's other entry points keep working unchanged.
44
44
  */
45
45
  export declare const SYSTEM: string;
46
- /** The document plus every id the model is allowed to cite, and nothing else. */
46
+ /**
47
+ * The document plus every id the model is allowed to cite, and nothing else.
48
+ *
49
+ * The figure block carries two facts beyond the caption — the section the image
50
+ * sat under and the prose that refers to it — because a planner that cannot see
51
+ * the picture has nothing else to decide what the picture is FOR.
52
+ */
47
53
  export declare function renderSource(source: Source): string;