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