@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,116 @@
1
+ /**
2
+ * Which beats survive a budget, and why each one that did not was cut.
3
+ *
4
+ * A format profile caps a length (src/types.ts, `DESTINATIONS`). When the
5
+ * narrated cut runs over, something has to go, and until now the only tool was
6
+ * `--min-weight`: a flat threshold on the author's salience number. A threshold
7
+ * is the wrong instrument, for three separate reasons, each of which the demo
8
+ * exhibits at 9:16.
9
+ *
10
+ * 1. IT IGNORES LENGTH. Weight says how much a beat matters; it says nothing
11
+ * about what it costs. At `--min-weight 0.85` the demo drops b08 (14.3s,
12
+ * weight 0.80) while keeping b02 (39.1s, weight 0.95) whole. Two 14-second
13
+ * beats at 0.80 are worth more than one 39-second beat at 0.95 to anybody
14
+ * who is trying to fit three minutes, and a threshold cannot express that.
15
+ * 2. IT IGNORES WHAT KIND OF BEAT IT IS. A deck is an argument. Weight is a
16
+ * per-beat number and has no view on the SHAPE of what is left, so a
17
+ * threshold will happily cut every chart and keep every claim — the exact
18
+ * failure mode of dropping the evidence and keeping the assertions.
19
+ * 3. IT IGNORES THE ENDS. `--min-weight 0.85` drops b12, the caveat the deck
20
+ * closes on, because the author scored the caveat lowest. A cut is a cut of
21
+ * the MIDDLE: an explanation that stops in the middle of its evidence has no
22
+ * ending, whatever the weights say.
23
+ *
24
+ * WHAT "BETTER" MEANS HERE, in priority order. A cut is better than another when
25
+ * it is coherent (this function never breaks an `inside` pair the author's own
26
+ * floor left intact), then when it keeps the
27
+ * ends and one beat of every archetype family the full deck used, then when it
28
+ * fits, and only then when it carries more author weight. Weight is the last
29
+ * tiebreak, not the objective — that inversion is the whole change.
30
+ *
31
+ * WHY NOT A PURE KNAPSACK ON WEIGHT. Because it is not neutral either: value
32
+ * per second systematically deletes the LONGEST beats, and a beat is long
33
+ * because its narration needed the words. Run unprotected on the demo it drops
34
+ * b02 (the method) and b03 (the comparison figure) — the two most-explained
35
+ * beats in the deck — for a higher weight total than the cut here. The
36
+ * protections below exist to say what the knapsack cannot see.
37
+ *
38
+ * SHORTENING WOULD BE BETTER THAN DROPPING, and it is not available here. A
39
+ * beat's length is measured speech (src/narrate), so the only way to shorten one
40
+ * is to write a shorter sentence, which is a planner decision made before any
41
+ * audio exists — `plan` would have to take a seconds budget and target
42
+ * words-per-beat, and the deck would keep all twelve beats at 15s each rather
43
+ * than nine at 20s. That is the better product and a different workstream; this
44
+ * function is what you need once the audio is already recorded, and what `verify`
45
+ * needs to explain an overrun it can only measure after the fact.
46
+ *
47
+ * PURE. No I/O, no clock, no randomness; integer arithmetic inside the optimiser
48
+ * so the same storyboard and budget always produce byte-identical output.
49
+ */
50
+ import { type Beat, type Ref, type Storyboard } from "../types.js";
51
+ /** What a caller must know about the target to make a cut. `Format` satisfies it. */
52
+ export interface SelectionBudget {
53
+ /** The author's floor. Beats below it never enter, as `--min-weight` does today. */
54
+ minWeight: number;
55
+ /** The hard ceiling in seconds. Absent or `Infinity` means nothing is cut. */
56
+ maxSeconds?: number;
57
+ /** Named in explanations, so a cut says which profile asked for it. */
58
+ id?: string;
59
+ }
60
+ /**
61
+ * Why a beat is not in the cut. Machine-readable; `reason` is the prose.
62
+ *
63
+ * There is deliberately no `orphaned`. A beat whose `inside` container was cut
64
+ * is KEPT and reported through `Cut.dangling` — see `dangling` for why deleting
65
+ * it was both unnecessary and destructive.
66
+ */
67
+ export type DropRule = "below_min_weight" | "over_budget";
68
+ export interface Dropped {
69
+ beat: Beat;
70
+ /** The length this beat would have contributed. */
71
+ seconds: number;
72
+ rule: DropRule;
73
+ /** One sentence naming the cause, with the numbers behind it. */
74
+ reason: string;
75
+ }
76
+ /**
77
+ * A kept beat that cites something no kept beat shows.
78
+ *
79
+ * Not a drop — the beat still stands on its own drawing — but the reference in
80
+ * it has gone dangling, and that is exactly the "as the comparison figure
81
+ * showed" failure the caller has to be able to see. The demo has a live one:
82
+ * b08 bar-compare cites `tbl-bench`, which only b09 data-table displays.
83
+ *
84
+ * `{ kind: "beat" }` is the same failure one level up: the beat happens INSIDE a
85
+ * beat this cut does not keep, so it opens on a hard cut where the author wrote
86
+ * a dive. See `dangling` for why that is a note and not a removal.
87
+ */
88
+ export interface Dangling {
89
+ beat: Beat;
90
+ ref: Ref | {
91
+ kind: "beat";
92
+ id: string;
93
+ };
94
+ reason: string;
95
+ }
96
+ export interface Cut {
97
+ /** In storyboard order. */
98
+ kept: Beat[];
99
+ /** In storyboard order. */
100
+ dropped: Dropped[];
101
+ /** Sum of the kept beats' seconds, rounded to 3dp like every other time here. */
102
+ seconds: number;
103
+ /** References inside `kept` whose subject is not in `kept`. */
104
+ dangling: Dangling[];
105
+ /** False when even the protected core does not fit — nothing here can save it. */
106
+ fits: boolean;
107
+ }
108
+ /**
109
+ * The cut for one budget.
110
+ *
111
+ * `seconds` maps a beat id to its NARRATED length — what `beatSeconds` in
112
+ * src/emit/composition.ts produces, or what a built composition's scene windows
113
+ * report. Beats absent from the map fall back to their authored `beat.seconds`,
114
+ * which is what an un-narrated storyboard has and all it has.
115
+ */
116
+ export declare function selectBeats(storyboard: Storyboard, budget: SelectionBudget, seconds?: Readonly<Record<string, number>>): Cut;
@@ -0,0 +1,43 @@
1
+ import type { z } from "zod";
2
+ import { prefsSchema } from "./types.js";
3
+ export type Prefs = z.infer<typeof prefsSchema>;
4
+ /**
5
+ * A partial Prefs. `narration` is the one nested object, so it is the one place
6
+ * a patch merges rather than replaces: `--voice` must not wipe a `rate` the
7
+ * config file set.
8
+ */
9
+ export type PrefsPatch = Partial<Omit<Prefs, "narration">> & {
10
+ narration?: Partial<Prefs["narration"]>;
11
+ };
12
+ export declare const CONFIG_FILE = "decksmith.config.json";
13
+ /**
14
+ * Resolve the preferences that govern one run.
15
+ *
16
+ * `cwd` is where the search for a config file starts; the search stops at the
17
+ * filesystem root. Returns a fully-populated Prefs — every consumer downstream
18
+ * reads fields, never optionals.
19
+ */
20
+ export declare function loadPrefs(overrides?: PrefsPatch, cwd?: string): Promise<Prefs>;
21
+ /**
22
+ * Turn CLI flags into a patch. Values arrive as strings from commander and are
23
+ * not validated here beyond the numbers: `loadPrefs` runs them through the
24
+ * schema, so there is exactly one place that decides what a legal tone is.
25
+ */
26
+ export declare function prefsFromFlags(flags: PrefFlags): PrefsPatch;
27
+ /** The flag surface the CLI exposes. Every one optional: absent means unstated. */
28
+ export interface PrefFlags {
29
+ slides?: string | number;
30
+ lang?: string;
31
+ tone?: string;
32
+ density?: string;
33
+ duration?: string | number;
34
+ theme?: string;
35
+ speed?: string | number;
36
+ narrate?: boolean;
37
+ voice?: string;
38
+ rate?: string;
39
+ pitch?: string;
40
+ subtitles?: boolean;
41
+ /** `--narration-density`. Spelled apart from `density`, which is a slide's. */
42
+ narrationDensity?: string;
43
+ }
@@ -0,0 +1,108 @@
1
+ import type { Cue } from "../deck/subtitles.js";
2
+ import type { BurnStyle } from "./ffmpeg.js";
3
+ /** Where `build` puts the subset woff2 and its `@font-face` rules. */
4
+ export declare const DECK_FONT_CSS = "assets/fonts/fonts.css";
5
+ /**
6
+ * One PNG per cue, and the single rectangle all of them occupy.
7
+ *
8
+ * ONE rectangle, not one per cue, and that is the point: a one-line cue and a
9
+ * two-line cue are clipped to the same box, so the filtergraph carries a
10
+ * constant `x:y` instead of per-cue geometry. Geometry that appears once cannot
11
+ * disagree with itself, and the transparent rows a short cue leaves behind cost
12
+ * nothing — PNG runs them out to a few bytes.
13
+ */
14
+ export interface CaptionBand {
15
+ /** Basenames inside the work directory, in cue order. */
16
+ files: string[];
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ }
22
+ /**
23
+ * The caption page: every cue laid out at the same place, all hidden.
24
+ *
25
+ * All of them in one document rather than one document per cue, because a
26
+ * navigation costs about as much as the screenshot does and there are dozens of
27
+ * cues. Hidden by default and revealed one at a time, so what is measured is
28
+ * exactly what is captured.
29
+ *
30
+ * The scrim is `box-decoration-break: clone`, which gives each LINE its own box
31
+ * — the same treatment ASS's `BorderStyle=4` produces, and the only one that
32
+ * reads as a caption rather than as a letterbox: a single box around a block
33
+ * whose second line is two words wide is a wide black bar with a short line
34
+ * floating in it.
35
+ *
36
+ * `line-height` is set from a measurement, not from a ratio. The inline box is
37
+ * as tall as the font's content area plus the padding, and that is a number only
38
+ * the font knows; guessing it either overlaps the two boxes — and two 70%
39
+ * scrims stacked composite to a visible dark seam across the middle of the band
40
+ * — or opens a gap the slide shows through. `--line` is written by `measure()`
41
+ * below once the font has actually loaded.
42
+ *
43
+ * THE BREAK IS OURS TO MAKE, and forgetting that cost a render. `splitCue` caps
44
+ * a cue at two lines' worth of characters, but `plan.cues` carries the text
45
+ * UNWRAPPED — `toSrt` calls `wrap` on its way out to the sidecar, so the hard
46
+ * `\n` exists only in the .srt. Handing the raw text to CSS let it soft-wrap at
47
+ * the container instead, and the first burned frame came out as a 49-character
48
+ * line over the orphan "up against": the same one-word-flash failure `splitCue`
49
+ * was fixed for, reappearing three modules downstream. So `wrap` is called here,
50
+ * the same function on the same text, and the burned band and the .srt break in
51
+ * the same places by construction rather than by coincidence.
52
+ *
53
+ * `white-space: pre-wrap` then honours those breaks, and still soft-wraps if a
54
+ * line somehow overruns rather than letting it run off the frame.
55
+ */
56
+ export declare function captionPage(cues: readonly Cue[], style: BurnStyle, fontHref: string | null): string;
57
+ /**
58
+ * Why captions cannot be drawn here, or null if they can.
59
+ *
60
+ * Asked BEFORE the capture, because discovering it afterwards is discovering it
61
+ * an hour late — the same reason the libass probe this replaced was asked early.
62
+ */
63
+ export declare function captionBlocker(): Promise<string | null>;
64
+ /**
65
+ * Write one transparent PNG per cue and report the box they share.
66
+ *
67
+ * `omitBackground` is what makes the PNG transparent outside the scrim;
68
+ * `deviceScaleFactor: 1` keeps a pixel a pixel on a retina host, without which
69
+ * every band comes back at twice the size and lands half off the frame.
70
+ */
71
+ export declare function renderCaptions(cues: readonly Cue[], style: BurnStyle, deck: string, work: string): Promise<CaptionBand>;
72
+ /**
73
+ * The rectangle every band fits in, snapped outwards to whole pixels.
74
+ *
75
+ * Snapped because a fractional clip makes Chrome resample, and a resampled
76
+ * caption is a soft caption. Every band shares a bottom edge — they are all
77
+ * bottom-anchored — so the union only ever grows upwards and sideways.
78
+ */
79
+ export declare function union(rects: readonly {
80
+ x: number;
81
+ y: number;
82
+ w: number;
83
+ h: number;
84
+ }[]): {
85
+ x: number;
86
+ y: number;
87
+ width: number;
88
+ height: number;
89
+ };
90
+ /**
91
+ * Chain one `overlay` per cue onto `input`, each gated to its own time range.
92
+ *
93
+ * HALF-OPEN, `gte * lt` rather than `between`: `between` closes both ends, and
94
+ * `splitCue` hands over at an exact shared instant, so every sentence boundary
95
+ * in the deck would put two captions on screen for one frame. This is the same
96
+ * `[start, end)` rule `activeCue` applies in the presented deck.
97
+ *
98
+ * A disabled `overlay` passes its frame through untouched, so the cost of the
99
+ * chain is one pointer copy per cue per frame, not one blend.
100
+ *
101
+ * `format=yuv444` keeps the alpha at full resolution through the blend. At the
102
+ * default `yuv420` the scrim's edges are composited from half-resolution alpha,
103
+ * which frays a 40px glyph's outline by a pixel — free to avoid, since the
104
+ * encode subsamples afterwards either way.
105
+ */
106
+ export declare function overlayGraph(cues: readonly Cue[], band: CaptionBand, input?: string, output?: string): string;
107
+ /** `-i` for every band PNG, in the order `overlayGraph` expects them. */
108
+ export declare function overlayInputs(band: CaptionBand): string[];
@@ -0,0 +1,129 @@
1
+ export interface RunOptions {
2
+ cwd?: string;
3
+ timeoutMs?: number;
4
+ }
5
+ /**
6
+ * Run a command, and on failure raise the last of its output rather than the
7
+ * whole log. ffmpeg says what is wrong in its final lines; the four hundred
8
+ * before them are the build configuration.
9
+ */
10
+ export declare function runTool(file: string, args: string[], opts?: RunOptions): Promise<{
11
+ stdout: string;
12
+ stderr: string;
13
+ }>;
14
+ /**
15
+ * Run a command with its stderr going straight to ours, live.
16
+ *
17
+ * `runTool` buffers, which is right for ffprobe and wrong for the capture: a
18
+ * seven-minute render that prints nothing until it ends is a render you cannot
19
+ * tell from a hung one, and the failure this verb is meant to survive is
20
+ * exactly a long job dying two thirds of the way through. Streaming means the
21
+ * frame counter is on screen when it stops.
22
+ */
23
+ export declare function runLive(file: string, args: string[]): Promise<void>;
24
+ export interface Probe {
25
+ width: number;
26
+ height: number;
27
+ /** Exact, from `r_frame_rate` — 30000/1001 must not become 29.97. */
28
+ fps: number;
29
+ frames: number;
30
+ seconds: number;
31
+ hasAudio: boolean;
32
+ }
33
+ /** What a rendered file actually is, as opposed to what we asked for. */
34
+ export declare function probe(path: string): Promise<Probe>;
35
+ /**
36
+ * Encoder settings, shared by every piece so the concat demuxer can copy.
37
+ *
38
+ * `-g 12` because each piece is opened at a keyframe anyway and short GOPs cost
39
+ * little at this bitrate; `veryfast` because the whole point of the piece
40
+ * pipeline is that it runs once per stop and there are dozens of them.
41
+ */
42
+ export declare function encoderArgs(fps: number): string[];
43
+ /**
44
+ * One piece: `motion` frames from `fromFrame`, then `freeze` clones of the last.
45
+ *
46
+ * `-ss` on the INPUT so the decoder seeks rather than decoding and discarding
47
+ * from zero — on a four-minute source the difference between the two is the
48
+ * difference between a minute and an hour. It is a keyframe seek refined to the
49
+ * requested time, so the first frame can land one frame either side of the one
50
+ * asked for; the piece's LENGTH is exact regardless, because `trim=end_frame`
51
+ * and `tpad=stop` count frames rather than seconds. A frame of content offset
52
+ * is invisible. A frame of length error would accumulate across dozens of
53
+ * pieces and pull every later sentence off its picture, which is the whole
54
+ * failure this feature exists to prevent.
55
+ */
56
+ export declare function pieceFilter(motion: number, freeze: number): string;
57
+ export declare function pieceArgs(source: string, fromFrame: number, motion: number, freeze: number, fps: number, out: string): string[];
58
+ export interface AudioInput {
59
+ /** Path to the mp3, as ffmpeg will be given it. */
60
+ file: string;
61
+ delayMs: number;
62
+ }
63
+ /**
64
+ * Delay every segment onto one track and sum them.
65
+ *
66
+ * `amix` with `normalize=0` sums rather than averaging: the default divides by
67
+ * the input count, which on a 37-segment deck would render the narration 31 dB
68
+ * down and sound exactly like a bug in the TTS. The segments never overlap — the
69
+ * timing model gives each one the video's undivided attention — so summing is
70
+ * safe. `dropout_transition=0` stops amix ramping the gain as inputs end.
71
+ *
72
+ * Every input is resampled and laid out identically first, because amix refuses
73
+ * a mismatch and edge-tts emits 24 kHz mono while the video wants 48 kHz.
74
+ * `apad` runs the track out to the video's length so the mux does not have to
75
+ * choose between a short audio stream and `-shortest` truncating the picture.
76
+ */
77
+ export declare function audioGraph(inputs: readonly AudioInput[], seconds: number,
78
+ /**
79
+ * ffmpeg input index of the first mp3. 1 when the video is the only other
80
+ * input; higher once the burned-in caption bands have taken 1..n, and getting
81
+ * this wrong points `adelay` at a PNG and mixes silence.
82
+ */
83
+ first?: number): string;
84
+ /**
85
+ * Speed a finished mp4 up by `factor`, pitch preserved.
86
+ *
87
+ * `setpts` compresses the video's timestamps and `atempo` time-stretches the
88
+ * audio without transposing it, so the voice stays the voice. This is the cheap
89
+ * half of duration control: MEASURED at 169.0s → 58.3s in 4.1 seconds of wall
90
+ * clock on experiments/010-burn-in/short.mp4, with no re-synthesis, no re-render
91
+ * and no rebuild. The expensive half — how much is said in the first place —
92
+ * happens back at plan time, because time-stretching cannot make 196 seconds of
93
+ * sentences into 60 seconds of listenable ones.
94
+ *
95
+ * `chain` is `tempoChain`'s, and it is the caller's because `atempo` clamps to
96
+ * 2.0 per instance and the decomposition is arithmetic that belongs where it can
97
+ * be tested with no ffmpeg — the rule this file's header states.
98
+ *
99
+ * `-fps_mode cfr` at the source's own rate is what turns compressed timestamps
100
+ * back into a normal file: frames are dropped to hold the frame rate, rather
101
+ * than the container being handed a 37.5 fps stream nothing expects.
102
+ */
103
+ export declare function respeedArgs(source: string, factor: number, chain: readonly number[], fps: number, hasAudio: boolean, out: string): string[];
104
+ export interface BurnStyle {
105
+ width: number;
106
+ height: number;
107
+ fontSize: number;
108
+ marginV: number;
109
+ marginX: number;
110
+ font: string;
111
+ }
112
+ /**
113
+ * How a burned-in caption is styled.
114
+ *
115
+ * Vertical and square are watched muted, so the caption is the format rather
116
+ * than an accessibility extra — it gets generous type and its own scrim.
117
+ *
118
+ * The band is drawn HERE and never in the composition. A band inside the slide
119
+ * collides with content the archetypes already fill their canvas with; that was
120
+ * measured once and fixed by moving the band out of the slide, and re-importing
121
+ * it as a composition element would re-import the collision.
122
+ */
123
+ export declare function burnStyle(width: number, height: number, font?: string): BurnStyle;
124
+ /**
125
+ * The band is drawn by ./captions.ts and composited with \`overlay\`, so nothing
126
+ * here converts a cue to a subtitle format. What used to live below was an ASS
127
+ * writer and a probe for the \`subtitles\` filter; both were deleted when the
128
+ * libass path turned out to be unavailable on the ffmpeg most customers have.
129
+ */
@@ -0,0 +1,123 @@
1
+ export type SubtitleMode = "auto" | "burn" | "sidecar" | "none";
2
+ /** What a mode actually does. Pure, so the default can be tested without ffmpeg. */
3
+ export interface SubtitlePlan {
4
+ /** Write the .srt beside the mp4. */
5
+ sidecar: boolean;
6
+ /** Cook the caption band into the picture too. */
7
+ burn: boolean;
8
+ }
9
+ /**
10
+ * SIDECAR BY DEFAULT. A burned-in caption is a decision taken away from the
11
+ * viewer: every player on earth has a subtitle toggle and none of them can turn
12
+ * off a band that is part of the picture. The band sits over the bottom ninth of
13
+ * the frame (`burnStyle` in ./ffmpeg.ts) and the archetypes do not know it is
14
+ * coming, so on a diagram that fills its canvas it lands on the content — which
15
+ * is the frame the owner opened and the reason this default flipped.
16
+ *
17
+ * `auto` used to mean "burn if the canvas is vertical or square". It is kept as
18
+ * a spelling of the default rather than removed, so an existing `--subtitles
19
+ * auto` in someone's script keeps working — but it no longer decides anything,
20
+ * because the thing it was deciding was not the renderer's to decide.
21
+ *
22
+ * Burning is still right for one case and it is a real one: a Reel or a Short
23
+ * posted to a feed, watched muted, where the platform's own caption track is
24
+ * either unavailable or off by default. That is `--subtitles burn`, typed on
25
+ * purpose, and it costs a full libx264 re-encode — the sidecar path copies the
26
+ * video bitstream through untouched.
27
+ */
28
+ export declare function subtitlePlan(mode: SubtitleMode): SubtitlePlan;
29
+ /**
30
+ * WHAT A REAL FIX NEEDS. Making the band optional did not stop it covering the
31
+ * slide; it only means you can now choose not to be covered. Anyone who wants
32
+ * `--subtitles burn` to be safe rather than merely available has to do this, and
33
+ * the shape of it is not obvious, so it is written down once here.
34
+ *
35
+ * THE MEASUREMENT. At 1080x1920 the band is 862x120px and `burnStyle`'s
36
+ * `marginV` puts its baseline 173px off the bottom, so it occupies the bottom
37
+ * 293px — 15.3% of the frame. Nothing in `src/emit` knows that. Extracted from
38
+ * two frames of the same deck rendered both ways, the difference is confined to
39
+ * exactly that strip (mean |Δ|Y of 8.2 there against 0.10 over the rest, which is
40
+ * re-encode noise), and in the strip it lands on the title rule and the sub-line.
41
+ *
42
+ * THE CAUSE, precisely: the burn decision is taken HERE, at render time, and the
43
+ * layout was fixed at build time. A render-time flag cannot move content that was
44
+ * positioned an hour earlier. So every step below exists to move the decision
45
+ * earlier, not to draw the band differently.
46
+ *
47
+ * 1. ONE SOURCE FOR THE BAND'S BOX. Today its height is implied by `burnStyle`
48
+ * (./ffmpeg.ts) plus the CSS in `captionPage` (./captions.ts) and is only
49
+ * known after a browser has laid the text out. Export a pure
50
+ * `bandReserve(width, height): number` — the bottom strip the band may
51
+ * occupy, `marginV` included, for the two-line worst case `wrap` and
52
+ * `splitCue` already bound. Both the renderer and the emitter must read that
53
+ * one function; a second copy of 0.09 and 0.037 is how this drifts back.
54
+ *
55
+ * 2. THE FORMAT HAS TO CARRY IT. `Format` in src/types.ts gains a
56
+ * `captionReserve: number`, defaulting to 0 and set by `build` when the
57
+ * caller says the render will burn — `build --subtitles burn`, or a
58
+ * `--reserve-captions` flag. It belongs on `Format` and not on `DeckOptions`
59
+ * because it changes the drawable box, which is what a format IS.
60
+ *
61
+ * 3. THE DRAWABLE BOX SHRINKS, IN ONE PLACE. `contentH` in src/emit/kit.ts
62
+ * becomes `height - 2·padY - captionReserve`. That is the whole geometric
63
+ * change: every archetype was audited for this and none of the twelve reads
64
+ * `format.height` directly — only `title.ts`, and only to ask which way up
65
+ * the canvas is. They all lay out into `contentH`, so subtracting there moves
66
+ * all of them at once. Note it must NOT go into `padY`: the padding is
67
+ * symmetric and the reserve is not, and a symmetric version would throw away
68
+ * as much off the top for nothing.
69
+ *
70
+ * 4. A GATE, OR THIS REGRESSES. `src/verify/check.ts` must fail a deck whose
71
+ * audience text falls inside the reserve — invariant 5 with a new floor. A
72
+ * purely geometric fix with no gate is one refactor away from being undone,
73
+ * and this failure is invisible to every gate that exists: the deck passes,
74
+ * the video renders, and only a person opening a frame ever finds out.
75
+ *
76
+ * 5. THE RENDERER REFUSES WHAT IT CANNOT HONOUR. `Timing` (./timing.ts) records
77
+ * the `captionReserve` the deck was built with, and `render --subtitles burn`
78
+ * errors when it is 0 — the same way `assertCapture` refuses a video that
79
+ * does not match its manifest. Otherwise the flag silently reintroduces the
80
+ * collision on any deck built before the reserve existed.
81
+ *
82
+ * NOT WORTH DOING: drawing the band inside the composition instead. That was
83
+ * tried and reverted (see `burnStyle`), and it makes the collision unconditional
84
+ * rather than opt-in, which is the wrong direction from where this now stands.
85
+ */
86
+ export interface RenderOptions {
87
+ /** A built deck directory — the one `build -o` wrote. */
88
+ deck: string;
89
+ /** Where the mp4 goes. */
90
+ out: string;
91
+ /** Skip capture and retime this file instead. */
92
+ video?: string;
93
+ workers?: string;
94
+ quality?: string;
95
+ fps?: number;
96
+ /** CDP protocol timeout in ms, handed to hyperframes. */
97
+ protocolTimeoutMs?: number;
98
+ subtitles?: SubtitleMode;
99
+ /**
100
+ * Speed the finished video up to land near this many seconds.
101
+ *
102
+ * Only ever a SPEED-UP, and only ever the last thing that happens: the deck's
103
+ * length is decided at plan time by how much it says, and this closes whatever
104
+ * gap survived that. A video already inside the target is left alone rather
105
+ * than padded — dead air is worse than eight seconds short.
106
+ */
107
+ targetSeconds?: number;
108
+ /** Leave the per-piece intermediates on disk. */
109
+ keep?: boolean;
110
+ /** Progress, one line at a time. */
111
+ log?: (message: string) => void;
112
+ }
113
+ export interface RenderResult {
114
+ out: string;
115
+ srt?: string;
116
+ seconds: number;
117
+ frames: number;
118
+ segments: number;
119
+ burned: boolean;
120
+ /** What `targetSeconds` cost, if anything. 1 means the file was not respeeded. */
121
+ playback: number;
122
+ }
123
+ export declare function render(opts: RenderOptions): Promise<RenderResult>;