@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,41 @@
|
|
|
1
|
+
import { type Beat, type Finding, type Format, type Storyboard } from "../types.js";
|
|
2
|
+
/** What the composition root records about itself. */
|
|
3
|
+
export interface Canvas {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
/** `data-duration` on the root: the whole deck, narration included. */
|
|
7
|
+
seconds: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read the root element's own attributes.
|
|
11
|
+
*
|
|
12
|
+
* Only the root carries `data-width`/`data-height` (`emitComposition`); scenes
|
|
13
|
+
* carry an id, a start and a duration and nothing else, which is exactly the
|
|
14
|
+
* attribute set EXPERIMENT-003 pinned. So "the div with all three" is an
|
|
15
|
+
* unambiguous handle, and a sub-composition file — should one ever exist — reads
|
|
16
|
+
* as `undefined` rather than as a deck of its own.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readCanvas(html: string): Canvas | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Which profiles could have produced this canvas.
|
|
21
|
+
*
|
|
22
|
+
* The composition records pixels, never a profile id, and `deck-16x9` and
|
|
23
|
+
* `video-16x9` share 1920x1080 — they differ only in navigability. Rather than
|
|
24
|
+
* guess between them, hold the deck to the most permissive budget any profile
|
|
25
|
+
* with this canvas allows: a gate that fails a deck for a limit that might not
|
|
26
|
+
* be its own is a gate people learn to ignore. Today the two 16:9 profiles have
|
|
27
|
+
* the same (absent) budget, so the reading is exact as well as safe.
|
|
28
|
+
*/
|
|
29
|
+
export declare function profilesFor(width: number, height: number): Format[];
|
|
30
|
+
/**
|
|
31
|
+
* The budget finding for one built composition, if there is one.
|
|
32
|
+
*
|
|
33
|
+
* `storyboard` is optional because `decksmith verify <dir>` has only the
|
|
34
|
+
* directory. With it, the message can name the beats to drop; without it, the
|
|
35
|
+
* overrun alone still says the useful half. `kept` is the list `build` actually
|
|
36
|
+
* emitted — scenes are beats in order, so a scene's window is the narrated
|
|
37
|
+
* length of the beat at the same index, and getting that pairing wrong would
|
|
38
|
+
* attribute one beat's seconds to another. Absent, it is re-derived with the
|
|
39
|
+
* flat threshold, which is the same list only while the budget cut nothing.
|
|
40
|
+
*/
|
|
41
|
+
export declare function scanBudget(html: string, storyboard?: Storyboard, kept?: readonly Beat[]): Finding[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Verdict } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* No `strict` option on purpose. Upstream's `--strict` fails on warnings, and
|
|
4
|
+
* every real deck emits `composition_file_too_large` — its limit is 300
|
|
5
|
+
* structural lines, which a seven-beat deck already passes. Its remedy is to
|
|
6
|
+
* split into sub-compositions, which is exactly the structure that makes a deck
|
|
7
|
+
* non-navigable (EXPERIMENT-003). So the flag could never pass, and a switch
|
|
8
|
+
* that cannot succeed is worse than no switch.
|
|
9
|
+
*/
|
|
10
|
+
export interface CheckOptions {
|
|
11
|
+
/** Also write the five contrast-pass PNGs to `<dir>/snapshots`. */
|
|
12
|
+
snapshots?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Extra moments to sample, in absolute deck seconds — the deck's own stops.
|
|
15
|
+
*
|
|
16
|
+
* WHY THE DEFAULT GRID IS NOT ENOUGH. Upstream samples nine midpoints across
|
|
17
|
+
* the whole duration, and a stop is a POINT: a twelve-beat deck is ~92s, so
|
|
18
|
+
* the grid lands on 5.1, 15.4, 25.6 … and the odds that any of them coincides
|
|
19
|
+
* with a hold are nil. Measured on a deliberately reverted `claim-figure` fix:
|
|
20
|
+
* the default grid reported 0 findings, and the same deck sampled at its own
|
|
21
|
+
* stops reported `canvas_overflow` on `#s11-c` and `#s11-e` at t=91.4. The
|
|
22
|
+
* overflow was there the whole time; nothing ever looked at the frame the
|
|
23
|
+
* audience actually sees.
|
|
24
|
+
*
|
|
25
|
+
* `regrade` still excuses anything inside a camera transit window, so widening
|
|
26
|
+
* the sampling cannot resurrect the mid-flight false positives that exemption
|
|
27
|
+
* exists for.
|
|
28
|
+
*/
|
|
29
|
+
at?: readonly number[];
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Run the HyperFrames gates over a built project directory. */
|
|
33
|
+
export declare function check(dir: string, opts?: CheckOptions): Promise<Verdict>;
|
|
34
|
+
/**
|
|
35
|
+
* The times to hand `--at`: the caller's stops UNION the grid they displace.
|
|
36
|
+
*
|
|
37
|
+
* THE UNION IS THE WHOLE POINT. `--at` is a replacement, not an addition
|
|
38
|
+
* (`buildLayoutSampleTimes` returns early on a non-empty `at`), so handing over
|
|
39
|
+
* a list of stops silently deletes nine samples that were doing real work —
|
|
40
|
+
* `data-table` at 7 rows is caught by a midpoint and by no stop of its own. A
|
|
41
|
+
* gate change that trades one class of defect for another is not a
|
|
42
|
+
* strengthening, and this is the line that stops it being one.
|
|
43
|
+
*
|
|
44
|
+
* Times past the end are dropped by upstream anyway; dropping them here keeps
|
|
45
|
+
* the argument honest about what was asked for. Rounded to 3dp because that is
|
|
46
|
+
* upstream's own `roundTime`, so a stop and its midpoint neighbour dedupe
|
|
47
|
+
* instead of running the same frame twice.
|
|
48
|
+
*/
|
|
49
|
+
export declare function sampleTimes(stops: readonly number[], duration: number): number[];
|
|
50
|
+
/**
|
|
51
|
+
* Turn one `hyperframes check --json` run into a verdict.
|
|
52
|
+
*
|
|
53
|
+
* Findings carry no `beatId`: the gates report DOM selectors, and mapping a
|
|
54
|
+
* selector back to a beat needs the scene→beat table that `emit` owns. The scene
|
|
55
|
+
* id survives inside the message (`[#s3 .term t=12.5s]`), which is enough to aim
|
|
56
|
+
* a repair round by hand until T2 makes it mechanical.
|
|
57
|
+
*
|
|
58
|
+
* `transit` defaults to none, which is the STRICT grading — a caller who does
|
|
59
|
+
* not supply the deck's camera windows gets every off-canvas finding as an
|
|
60
|
+
* error, exactly as before the camera existed.
|
|
61
|
+
*/
|
|
62
|
+
export declare function parseCheckReport(stdout: string, stderr?: string, transit?: Window[]): Verdict;
|
|
63
|
+
/** One camera transit, `[t0, t1]` in absolute deck seconds. */
|
|
64
|
+
/**
|
|
65
|
+
* A camera's transit window, and WHOSE it is.
|
|
66
|
+
*
|
|
67
|
+
* The sid matters because the guarantee is not symmetric. `assertStopsOutsideMove`
|
|
68
|
+
* promises that no stop of the DIPPING scene lands inside its own move; it
|
|
69
|
+
* promises nothing about the INCOMING scene, whose first stop can fall inside the
|
|
70
|
+
* tail (a review built exactly that deck to check). So an exemption granted on
|
|
71
|
+
* time alone excuses a finding about a scene the emitter never vouched for.
|
|
72
|
+
*/
|
|
73
|
+
type Window = {
|
|
74
|
+
sid: string;
|
|
75
|
+
t0: number;
|
|
76
|
+
t1: number;
|
|
77
|
+
};
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Verdict } from "../types.js";
|
|
2
|
+
/** Below this, two renders of the same deck differ in a way a viewer would see. */
|
|
3
|
+
export declare const FLOOR_DB = 40;
|
|
4
|
+
/**
|
|
5
|
+
* The archetypes a byte-identical fixture may use.
|
|
6
|
+
*
|
|
7
|
+
* Everything left out tweens `scale` on something that carries text, or draws a
|
|
8
|
+
* raster: `equation-walk` scales the term it is explaining, `grid` scales whole
|
|
9
|
+
* labelled cells, `line-chart` pops its dots from zero, and the two figure
|
|
10
|
+
* archetypes place a decoded bitmap. Scaling an outline hands Skia a glyph-cache
|
|
11
|
+
* decision that comes out differently between runs (EXPERIMENT-006), which is
|
|
12
|
+
* exactly the noise `identical` mode must not contain — so a fixture that grew
|
|
13
|
+
* one of these would start failing for a reason that is not a defect.
|
|
14
|
+
*
|
|
15
|
+
* Enforced by `test/drift.test.ts` against the committed fixture, because the
|
|
16
|
+
* pressure on a fixture is always to make it more interesting.
|
|
17
|
+
*/
|
|
18
|
+
export declare const FIXTURE_SAFE_ARCHETYPES: ReadonlySet<string>;
|
|
19
|
+
export type DriftMode = "identical" | "psnr";
|
|
20
|
+
export interface DriftOptions {
|
|
21
|
+
/** Default `psnr`. Use `identical` only on a deck that can actually hold it. */
|
|
22
|
+
mode?: DriftMode;
|
|
23
|
+
/** Per-frame PSNR floor in dB. Ignored in `identical` mode. */
|
|
24
|
+
floorDb?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Passed to `hyperframes render -w`. Worker sharding was tested and refuted as
|
|
27
|
+
* a source of drift (EXPERIMENT-006, hypothesis 2), so this defaults to `auto`
|
|
28
|
+
* and exists to re-test that, not to work around it.
|
|
29
|
+
*/
|
|
30
|
+
workers?: number | "auto";
|
|
31
|
+
/** Where the two frame directories go. A fresh temp directory by default. */
|
|
32
|
+
workDir?: string;
|
|
33
|
+
/** Keep the frames even when the gate passes. They are kept on failure regardless. */
|
|
34
|
+
keep?: boolean;
|
|
35
|
+
/** Per render, not for the pair. */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface DriftReport extends Verdict {
|
|
39
|
+
mode: DriftMode;
|
|
40
|
+
frames: number;
|
|
41
|
+
/** Frames whose two PNGs are byte for byte the same. */
|
|
42
|
+
identical: number;
|
|
43
|
+
/** The lowest per-frame PSNR and the 1-based frame it fell on. Absent when nothing differed. */
|
|
44
|
+
worst?: {
|
|
45
|
+
frame: number;
|
|
46
|
+
db: number;
|
|
47
|
+
};
|
|
48
|
+
/** The two frame directories, when they were kept. */
|
|
49
|
+
kept?: {
|
|
50
|
+
a: string;
|
|
51
|
+
b: string;
|
|
52
|
+
};
|
|
53
|
+
/** What moved inside the first render. */
|
|
54
|
+
motion: Motion;
|
|
55
|
+
}
|
|
56
|
+
/** One scene's window as the composition declares it, in seconds. */
|
|
57
|
+
export interface SceneWindow {
|
|
58
|
+
id: string;
|
|
59
|
+
start: number;
|
|
60
|
+
duration: number;
|
|
61
|
+
}
|
|
62
|
+
/** What changed inside a single render. */
|
|
63
|
+
export interface Motion {
|
|
64
|
+
/** Scenes that had frames of their own to compare, and did change. */
|
|
65
|
+
live: readonly string[];
|
|
66
|
+
/** Scenes that had frames of their own to compare, and did not. */
|
|
67
|
+
frozen: readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* Scenes with fewer than two frames the scene does not share with a
|
|
70
|
+
* neighbour. Reported rather than counted as a pass: "no rule applied" and
|
|
71
|
+
* "the rule passed" have to stay distinguishable.
|
|
72
|
+
*/
|
|
73
|
+
unmeasured: readonly string[];
|
|
74
|
+
/** Distinct frames across the whole render — the only measure when no scene could be read. */
|
|
75
|
+
distinct: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `<div data-composition-id="s3" data-start="…" data-duration="…">` for every
|
|
79
|
+
* scene, in document order.
|
|
80
|
+
*
|
|
81
|
+
* The attribute triple is EXPERIMENT-003's, and `emitComposition` puts it on
|
|
82
|
+
* scenes and nothing else — `scanBudget` reads the same three. Anchored on the
|
|
83
|
+
* opening tag rather than swept with one greedy pattern because a `[\s\S]{0,200}`
|
|
84
|
+
* bridge between two attributes reads across the boundary into the next element
|
|
85
|
+
* on a scene whose attribute order ever changes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readScenes(html: string): SceneWindow[];
|
|
88
|
+
/**
|
|
89
|
+
* Did anything move, and where not?
|
|
90
|
+
*
|
|
91
|
+
* PER SCENE, not per deck, because the deck-level question has no threshold that
|
|
92
|
+
* survives contact. MEASURED on the frozen fixture: 210 frames, 3 distinct
|
|
93
|
+
* images, 2 frame-to-frame changes — one per scene switch, which HyperFrames
|
|
94
|
+
* performs itself and which happens whether or not the deck's own timeline ever
|
|
95
|
+
* ran. A twelve-scene frozen deck would show about twelve. So "more than N
|
|
96
|
+
* distinct frames" is a bar that rises with the scene count and can be cleared by
|
|
97
|
+
* a deck in which nothing the emitter wrote has any effect at all. Per scene the
|
|
98
|
+
* bar is fixed and needs no calibration: a scene either changed during its own
|
|
99
|
+
* window or it did not.
|
|
100
|
+
*
|
|
101
|
+
* A RATIO does not work either, and the two decks say why: the fixture's live
|
|
102
|
+
* render is 138 distinct frames in 210 (65.7%) and the shipped demo's is 1198 in
|
|
103
|
+
* 7395 (16.2%), because narration stretches every beat and a hold is one image
|
|
104
|
+
* for as long as it lasts. A threshold set from the fixture fails the demo; one
|
|
105
|
+
* set from the demo passes a deck where ten of twelve scenes are dead.
|
|
106
|
+
*
|
|
107
|
+
* ITS OWN WINDOW means the frames it does not share with a neighbour. Scenes
|
|
108
|
+
* overlap by a crossfade — the fixture's s1 is [0, 3.4) and s2 is [3, 7) — and
|
|
109
|
+
* those shared frames carry the switch, which is exactly the change that is not
|
|
110
|
+
* evidence of the scene animating. Dropping them takes the frozen fixture's
|
|
111
|
+
* per-scene count from 1 change to 0, against 55 and 92 in the live render, so
|
|
112
|
+
* the rule is "at least one change", with no constant to argue about.
|
|
113
|
+
*
|
|
114
|
+
* That leaves the partial freeze visible too: one archetype whose reveal is
|
|
115
|
+
* driven by a callback fails its own scene while the other eleven pass.
|
|
116
|
+
*/
|
|
117
|
+
export declare function measureMotion(hashes: readonly string[], scenes: readonly SceneWindow[], seconds: number): Motion;
|
|
118
|
+
/**
|
|
119
|
+
* Render `dir` twice and compare.
|
|
120
|
+
*
|
|
121
|
+
* `dir` is a built deck directory — the thing `decksmith build -o` writes, with
|
|
122
|
+
* `index.html` and `hyperframes.json` in it.
|
|
123
|
+
*/
|
|
124
|
+
export declare function drift(dir: string, opts?: DriftOptions): Promise<DriftReport>;
|
|
125
|
+
/**
|
|
126
|
+
* Turn the measurement into a verdict. Pure, and separated from the plumbing
|
|
127
|
+
* because this is the part with an opinion in it — everything above just shells
|
|
128
|
+
* out and hashes.
|
|
129
|
+
*/
|
|
130
|
+
export declare function judge(m: {
|
|
131
|
+
mode: DriftMode;
|
|
132
|
+
floorDb: number;
|
|
133
|
+
frames: number;
|
|
134
|
+
/** 1-based indices of the frames whose two PNGs differ. */
|
|
135
|
+
differing: readonly number[];
|
|
136
|
+
/** Required whenever `differing` is non-empty. */
|
|
137
|
+
worst?: {
|
|
138
|
+
frame: number;
|
|
139
|
+
db: number;
|
|
140
|
+
};
|
|
141
|
+
motion: Motion;
|
|
142
|
+
}): DriftReport;
|
|
143
|
+
/**
|
|
144
|
+
* `frame_000001.png` → `frame_%06d.png`, for ffmpeg's image2 demuxer.
|
|
145
|
+
*
|
|
146
|
+
* Derived from what the renderer actually wrote rather than hardcoded, because
|
|
147
|
+
* the naming is HyperFrames' business: a version that renames its frames should
|
|
148
|
+
* make this say so, not silently compare nothing.
|
|
149
|
+
*/
|
|
150
|
+
export declare function framePattern(name: string): {
|
|
151
|
+
pattern: string;
|
|
152
|
+
start: number;
|
|
153
|
+
} | undefined;
|
|
154
|
+
/** The lowest `psnr_avg` in an ffmpeg stats stream, and the frame it fell on. */
|
|
155
|
+
export declare function worstFrame(stats: string): {
|
|
156
|
+
frame: number;
|
|
157
|
+
db: number;
|
|
158
|
+
} | undefined;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FIDELITY GATE — at each stop, did the thing this slide is about appear?
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Four of the twenty compositional decks in
|
|
5
|
+
* `experiments/015-decision/` are GATE-CLEAN and draw their main diagram as
|
|
6
|
+
* nothing at all: eight to fifteen drawables per deck, sized legally, placed
|
|
7
|
+
* legally, and multiplied to zero opacity by a group they were nested in.
|
|
8
|
+
* `out/f-vocab18.png` is a headline over three grey arrowheads. `lint`,
|
|
9
|
+
* `runtime`, `layout`, `motion`, `contrast`, the 40px floor and `drift` all pass
|
|
10
|
+
* on it — `drift` twice over, because both renders are identically empty. Every
|
|
11
|
+
* gate in this repo reads what the DOM CONTAINS. This one reads what the frame
|
|
12
|
+
* SHOWS, which is the only thing the audience gets.
|
|
13
|
+
*
|
|
14
|
+
* WHERE IT MEASURES, AND WHY NOT THE CHEAPER PLACE. Two prototypes exist.
|
|
15
|
+
* `invisible.mjs` does arithmetic over the plan — effective opacity as the
|
|
16
|
+
* product down the parent chain — and found 4 of 20. `ink.mjs` renders the deck
|
|
17
|
+
* and counts non-background pixels at every hold, and found 5 of 20. The
|
|
18
|
+
* arithmetic is ~1000x cheaper and it is NOT the gate, for a reason that is
|
|
19
|
+
* structural rather than aesthetic: the shipped path has no plan to do
|
|
20
|
+
* arithmetic over. A DeckSmith storyboard never mentions opacity — revealing
|
|
21
|
+
* what a beat draws is the archetype's job — so on the artifact this project
|
|
22
|
+
* actually produces there is nothing for `invisible.mjs` to read. It cannot be
|
|
23
|
+
* a pre-filter either, for the same reason. Pixels are the only instrument that
|
|
24
|
+
* works on both authoring paths, so pixels are the gate.
|
|
25
|
+
*
|
|
26
|
+
* WHICH PIXELS. Not `hyperframes snapshot`, which is the obvious cheap frame
|
|
27
|
+
* source and is unsound here: it calls `player.renderSeek(t)` with no options
|
|
28
|
+
* (`cli.js`, `seekCompositionTimeline`), so `suppressEvents` is falsy and a GSAP
|
|
29
|
+
* `onUpdate` FIRES. Invariant 11 says the capture path suppresses events, so
|
|
30
|
+
* callback-driven motion plays under snapshot and renders frozen — snapshot is
|
|
31
|
+
* permissive in exactly the direction that hides the failure this gate is for.
|
|
32
|
+
* So the gate drives the capture path's own two calls: inject the pinned
|
|
33
|
+
* hyperframes runtime, `renderSeek(t, { suppressEvents: true })`, then
|
|
34
|
+
* `Page.captureScreenshot` with the renderer's own clip parameters. Measured
|
|
35
|
+
* against `experiments/015-decision/out/vocab-18.mp4` — a real render — at all
|
|
36
|
+
* twelve of its holds, this agrees to a worst case of 0.11 and a mean of 0.03
|
|
37
|
+
* percentage points, the residual being H.264 quantisation (the video's
|
|
38
|
+
* background reads 11,13,15 where the screenshot reads 11,13,16).
|
|
39
|
+
*
|
|
40
|
+
* WHAT COUNTS AS INK. Pixels that differ from the frame's own modal colour by
|
|
41
|
+
* more than `INK_DELTA` on any channel. `ink.mjs` used absolute luma > 26, which
|
|
42
|
+
* is half of the `ink` theme's background and therefore counts 100% of a `mono`
|
|
43
|
+
* deck's white frame as ink; a shipped gate cannot be theme-specific. The modal
|
|
44
|
+
* colour IS the background — it holds 80–98% of every frame measured — and one
|
|
45
|
+
* line of 40px type comes out at 0.684% of the band on `ink`, 0.669% on `mono`
|
|
46
|
+
* and 0.668% on `paper`, so the measure is theme-independent to within 2.4%.
|
|
47
|
+
* This is also where `ink.mjs` and `invisible.mjs` disagreed: `ink.mjs` called
|
|
48
|
+
* `vocab-10` empty at 0.376%, under its 0.4% threshold, because luma > 26 misses
|
|
49
|
+
* a panel fill that is drawn but dim. Background-relative, `vocab-10`'s emptiest
|
|
50
|
+
* hold is 3.705% — nine times its own reported value and nowhere near any floor.
|
|
51
|
+
* The disagreement was the instrument, not the deck: the true count is 4 of 20.
|
|
52
|
+
*
|
|
53
|
+
* WHERE IT LOOKS. Below the slide's caption. Whole-frame ink does not
|
|
54
|
+
* discriminate — measured, MENU's median hold is 16.5% of the frame and VOCAB's
|
|
55
|
+
* is 3.4%, but the hand-written control is 3.6%, sitting among the failures
|
|
56
|
+
* rather than above them, because the headline alone is 1.5–2% and every deck
|
|
57
|
+
* has one. `ink.mjs` cut a fixed band at 0.22H. That band is wrong for this
|
|
58
|
+
* emitter: a two-line DeckSmith `.headline` reaches 0.309H at 16:9 and 0.382H on
|
|
59
|
+
* the twelve-beat demo's last scene, so a fixed 0.22H leaves most of the caption
|
|
60
|
+
* inside the measured region, where its ink would mask an empty body. So the
|
|
61
|
+
* band's top comes from the artifact — the bottom of the active scene's own
|
|
62
|
+
* caption — and 0.22H is only the fallback for a composition that names none.
|
|
63
|
+
* The control that had to be run: blank the archetype on four of the demo's
|
|
64
|
+
* scenes and measure what is left below the caption. It is 0.0000% on all four,
|
|
65
|
+
* so a DeckSmith slide whose beat drew nothing reads exactly zero. The gate is
|
|
66
|
+
* not being propped up by chrome.
|
|
67
|
+
*
|
|
68
|
+
* THE FLOOR IS NOT FITTED. 0.15% of the frame is the measured ink of ONE SHORT
|
|
69
|
+
* LABEL at the 40px audience floor of invariant 5 (0.213% of a 0.73H band =
|
|
70
|
+
* 0.155% of the frame, measured in Chrome, three themes). Below that the body
|
|
71
|
+
* holds less ink than the smallest legible thing this project permits. The
|
|
72
|
+
* corpus then CHECKS it rather than setting it: over 466 stops the worst
|
|
73
|
+
* positive reads 0.039% of the frame and the best negative 0.663%, so the floor
|
|
74
|
+
* derived from the type gate lands 3.8x above one and 4.4x below the other. Had
|
|
75
|
+
* it landed outside that gap, the floor would have been wrong and the gap would
|
|
76
|
+
* have said so. Fitting it would have looked exactly like this and meant
|
|
77
|
+
* nothing, which is why the number came from somewhere else first.
|
|
78
|
+
*
|
|
79
|
+
* ACCEPTANCE, from `fidelity()` itself and not a lookalike. 41 decks, 466 stops:
|
|
80
|
+
* TP 4, FP 0, TN 37, FN 0. It flags `vocab-11`, `-13`, `-16` and `-18` and names
|
|
81
|
+
* the scene (`s01-flow` in all four). It flags none of arm MENU's 20, none of the
|
|
82
|
+
* twelve other VOCAB decks, not the hand-written control, and not the twelve-beat
|
|
83
|
+
* demo in any of its four formats — whose tightest margin is 7.6x the floor
|
|
84
|
+
* (9:16, 1.138%; 16:9 and video 1.732%; 1x1 1.704%) — nor `demo/fixtures/`
|
|
85
|
+
* (plain 4.435%, camera 7.181%). Re-run three times, every number identical to
|
|
86
|
+
* four decimals: this gate does not flake, which matters more than its sensitivity
|
|
87
|
+
* because a false positive on a good slide is how `composition_file_too_large`
|
|
88
|
+
* became something everyone ignores.
|
|
89
|
+
*
|
|
90
|
+
* The number that did NOT move is the one worth the most. Arm MENU is 0 of 20
|
|
91
|
+
* under `invisible.mjs`, 0 of 20 under `ink.mjs` and 0 of 20 here — three
|
|
92
|
+
* different measures, three different thresholds, one answer — which is what
|
|
93
|
+
* makes 4 of 20 on the other arm a property of composition rather than of
|
|
94
|
+
* whoever wrote the checker.
|
|
95
|
+
*
|
|
96
|
+
* THE CONTROL THAT COULD HAVE REFUTED IT. Case thirteen is not expressible on the
|
|
97
|
+
* shipped path, so it was forced: a copy of the twelve-beat demo with one CSS
|
|
98
|
+
* rule holding every scene's content at opacity 0 while the captions reveal
|
|
99
|
+
* normally. Every other gate passes it with ZERO errors and ZERO warnings — it
|
|
100
|
+
* even loses `connector_detached`, because an invisible connector detaches from
|
|
101
|
+
* nothing — and this gate fails it with 12 errors, 37 of 37 stops at 0.0000%. The
|
|
102
|
+
* same deck unbroken reads 1.732% and passes. That is the fourteenth case, caught
|
|
103
|
+
* before it shipped rather than after.
|
|
104
|
+
*
|
|
105
|
+
* WHAT WAS TRIED AND REFUTED. "Ink INCREASED across the reveal" is the more
|
|
106
|
+
* appealing signal — it promises to excuse a sparse slide — and the corpus kills
|
|
107
|
+
* it. Per scene, last hold minus first: the hand-written control's scene 2 gains
|
|
108
|
+
* 0.71 points while every broken scene gains more (vocab-11 0.80, vocab-13 1.04,
|
|
109
|
+
* vocab-18 1.28, vocab-16 1.80), because a scene that starts at zero and ends
|
|
110
|
+
* faint still increases. Per-scene MAXIMUM ink fails too, and by a hair that is
|
|
111
|
+
* worse than a wide miss: vocab-16's broken third scene peaks at 2.51% against
|
|
112
|
+
* the control's honest 2.44%. Presence at a stop is the signal; nothing else in
|
|
113
|
+
* the corpus separates.
|
|
114
|
+
*
|
|
115
|
+
* WHAT IT STILL CANNOT SEE, written down here so it is not rediscovered as a
|
|
116
|
+
* surprise. It answers "something was drawn", never "something was legible": a
|
|
117
|
+
* panel wash 13 levels off the background clears the floor while being nearly
|
|
118
|
+
* invisible, and nothing in the stack measures contrast for a SHAPE — `contrast`
|
|
119
|
+
* only grades runs of text. It measures only at declared stops, so a scene with
|
|
120
|
+
* no holds is not looked at, on the grounds that the deck never claims the
|
|
121
|
+
* audience is stopped there. And it is per-scene, not per-element, which costs a
|
|
122
|
+
* real case: `vocab-16`'s third scene is a bar chart whose FIVE BARS are all
|
|
123
|
+
* invisible while its value labels, axis and category labels are not — opened, it
|
|
124
|
+
* is five numbers floating over an axis — and it reads 0.520% at its emptiest
|
|
125
|
+
* stop, 3.5x the floor, so this gate passes it. The deck is still caught, on its
|
|
126
|
+
* first scene, so the confusion matrix does not show the miss; the scene-level
|
|
127
|
+
* miss is real all the same. No threshold fixes it, because the labels genuinely
|
|
128
|
+
* appeared. What would fix it is knowing that a bar chart must have bars — the
|
|
129
|
+
* beat's intent, which is precisely what arm MENU's archetypes encode and a
|
|
130
|
+
* composed plan does not.
|
|
131
|
+
*
|
|
132
|
+
* COST, and this is why it is not behind a flag. Alone it is 3.8–4.7s for the
|
|
133
|
+
* twelve-beat demo's 37 stops — one browser, one page, one screenshot each. In
|
|
134
|
+
* `verify` it is FREE: it runs concurrently with `check`, which is a child
|
|
135
|
+
* process on its own Chrome, and finishes first. Measured, three runs each:
|
|
136
|
+
* `verify --no-fidelity` 5.66 / 5.77 / 5.75s, `verify` 5.61 / 5.64 / 5.66s. So
|
|
137
|
+
* the marginal wall cost is zero and the difference is noise, which was NOT the
|
|
138
|
+
* expected answer — the estimate before measuring was +7%. `npm run score` on the
|
|
139
|
+
* demo moves 5.81s to 6.32s, and that is a cold cache. A gate too slow to run is
|
|
140
|
+
* a gate nobody runs; this one costs nothing, so it runs by default.
|
|
141
|
+
*/
|
|
142
|
+
import type { Finding } from "../types.js";
|
|
143
|
+
/**
|
|
144
|
+
* Ink at a stop, as a fraction of the WHOLE frame — not of the measured band.
|
|
145
|
+
*
|
|
146
|
+
* The band's top moves with the caption, so a band-relative fraction would move
|
|
147
|
+
* the threshold every time a headline wrapped to another line. Normalising by
|
|
148
|
+
* the frame instead makes the number an amount of ink, and tightening the band
|
|
149
|
+
* can then only lower it. That is the safe direction: a stricter band cannot
|
|
150
|
+
* make a blank slide pass.
|
|
151
|
+
*/
|
|
152
|
+
export declare const INK_FLOOR = 0.0015;
|
|
153
|
+
/** One declared stop: which scene, and where on the deck timeline. */
|
|
154
|
+
export interface Stop {
|
|
155
|
+
/** Composition id — `s1`, `s2`. */
|
|
156
|
+
sid: string;
|
|
157
|
+
/** Absolute seconds on the composition timeline. */
|
|
158
|
+
t: number;
|
|
159
|
+
}
|
|
160
|
+
export interface Measured extends Stop {
|
|
161
|
+
/** Non-background pixels below the caption, over the whole frame's pixels. */
|
|
162
|
+
ink: number;
|
|
163
|
+
/** Where the band began, as a fraction of frame height. Reported for triage. */
|
|
164
|
+
bandTop: number;
|
|
165
|
+
}
|
|
166
|
+
export interface FidelityOptions {
|
|
167
|
+
floor?: number;
|
|
168
|
+
/**
|
|
169
|
+
* Stops to measure. Supplied by the 015 calibration harness, whose decks are
|
|
170
|
+
* not DeckSmith builds and carry neither `timing.json` nor an island; the
|
|
171
|
+
* shipped path reads them off the artifact.
|
|
172
|
+
*/
|
|
173
|
+
stops?: readonly Stop[];
|
|
174
|
+
timeoutMs?: number;
|
|
175
|
+
}
|
|
176
|
+
export interface FidelityReport {
|
|
177
|
+
stops: Measured[];
|
|
178
|
+
/**
|
|
179
|
+
* Both gates' findings — this one's `blank_at_stop` and `overprint`'s
|
|
180
|
+
* `svg_text_overprint`.
|
|
181
|
+
*
|
|
182
|
+
* They share a report because they share a browser, a page and a seek: the
|
|
183
|
+
* collision rule is one `page.evaluate` inside the loop below. Keeping it in
|
|
184
|
+
* its own module and folding the findings in here is the split that costs
|
|
185
|
+
* nothing — see `verify/overprint.ts` for why the rule has to exist at all.
|
|
186
|
+
*/
|
|
187
|
+
findings: Finding[];
|
|
188
|
+
elapsedMs: number;
|
|
189
|
+
}
|
|
190
|
+
export interface Frame {
|
|
191
|
+
width: number;
|
|
192
|
+
height: number;
|
|
193
|
+
/** 3 for RGB, 4 for RGBA. */
|
|
194
|
+
channels: number;
|
|
195
|
+
pixels: Uint8Array;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Chrome's screenshot PNG, unpacked.
|
|
199
|
+
*
|
|
200
|
+
* Written out rather than taken from a dependency because `fflate` already ships
|
|
201
|
+
* for the pack format and supplies the only hard part; what is left is one
|
|
202
|
+
* chunk walk and one unfilter. `ffmpeg` would also do it, but `verify` does not
|
|
203
|
+
* otherwise need ffmpeg and a gate that needs a second binary is a gate that
|
|
204
|
+
* gets skipped on the machine that lacks it.
|
|
205
|
+
*/
|
|
206
|
+
export declare function decodePng(png: Uint8Array): Promise<Frame>;
|
|
207
|
+
/**
|
|
208
|
+
* Non-background pixels from `bandTopPx` down, over the whole frame's pixels.
|
|
209
|
+
*
|
|
210
|
+
* Pure and exported so the measurement can be tested without a browser — and so
|
|
211
|
+
* that "what this counts" is a thing someone can read in twenty lines rather
|
|
212
|
+
* than infer from a gate's verdict. It counts PIXELS. It does not read a
|
|
213
|
+
* bounding box, and it does not ask the DOM whether an element is visible; both
|
|
214
|
+
* of those have been wrong in this project inside the last week.
|
|
215
|
+
*/
|
|
216
|
+
export declare function inkBelow(frame: Frame, bandTopPx: number): number;
|
|
217
|
+
/**
|
|
218
|
+
* The stops a built deck declares, from `timing.json` if it is there and the
|
|
219
|
+
* slideshow island if it is not.
|
|
220
|
+
*
|
|
221
|
+
* `timing.json` is preferred because it exists for every format — `short-9x16`
|
|
222
|
+
* emits no navigable page and so no island — and because it names the scene each
|
|
223
|
+
* stop belongs to, which is what makes a finding aimable. Its `holds` are
|
|
224
|
+
* SCENE-RELATIVE and its `scenes[].start` absolute; the island's `fragments` are
|
|
225
|
+
* already absolute. (`TimedScene.holds`' doc comment in `src/render/timing.ts`
|
|
226
|
+
* says "absolute" and is stale — `framePlan` adds `scene.start` at line 265, and
|
|
227
|
+
* line 370 says so outright.)
|
|
228
|
+
*/
|
|
229
|
+
export declare function readStops(timing: string | null, deckPage: string | null): Stop[];
|
|
230
|
+
/**
|
|
231
|
+
* Findings for the stops whose body drew nothing.
|
|
232
|
+
*
|
|
233
|
+
* One finding per SCENE rather than per stop. The failure is a scene whose
|
|
234
|
+
* content never arrives, and vocab-16 has five such stops in one scene — five
|
|
235
|
+
* lines saying the same thing is how a report becomes wallpaper. The worst stop
|
|
236
|
+
* is named because that is the frame to open.
|
|
237
|
+
*/
|
|
238
|
+
export declare function gradeFidelity(rows: readonly Measured[], floor?: number): Finding[];
|
|
239
|
+
/**
|
|
240
|
+
* Seek every declared stop and count the ink below its caption.
|
|
241
|
+
*
|
|
242
|
+
* Never throws for an environmental reason: a machine that cannot open a browser
|
|
243
|
+
* gets a WARNING saying the gate did not run, because "the instrument is
|
|
244
|
+
* missing" and "the deck is blank" are different claims and only the second is
|
|
245
|
+
* the deck's fault. A stop that *was* measured and came up empty is an error.
|
|
246
|
+
*/
|
|
247
|
+
export declare function fidelity(dir: string, opts?: FidelityOptions): Promise<FidelityReport>;
|