browser-broker 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/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { StartupRefusal } from "../errors.js";
|
|
4
|
+
import { profileDirectory } from "./discovery.js";
|
|
5
|
+
import { DEFAULT_BROWSER_IDS } from "./driver.js";
|
|
6
|
+
/** The refusals this module raises, spelled as §7.2 spells them. */
|
|
7
|
+
export const SETUP_RULES = {
|
|
8
|
+
profileNeverDestroyed: 'setup.profile_never_destroyed',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The lock a running browser leaves in its profile directory.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **This is checked as evidence, never as a gate, and the difference is
|
|
14
|
+
* measured.** The single-instance lock a POSIX system leaves behind **does
|
|
15
|
+
* not exist on Windows**, so a cross-platform check looking for it does not
|
|
16
|
+
* report *no lock* there — it **always passes**. A guard that cannot fail on
|
|
17
|
+
* one platform is worse than no guard, because it is trusted equally on both.
|
|
18
|
+
*
|
|
19
|
+
* So this is used only in the direction where a positive result is
|
|
20
|
+
* meaningful: **finding one means a browser is very likely running**, which
|
|
21
|
+
* is worth naming in a refusal. Not finding one means **nothing at all**, and
|
|
22
|
+
* this module never concludes a profile is free from its absence. What
|
|
23
|
+
* actually establishes whether a browser is running is the verified discovery
|
|
24
|
+
* record (§1.2c), which works on every platform.
|
|
25
|
+
*/
|
|
26
|
+
const LOCK_ENTRIES = ['SingletonLock', 'SingletonSocket'];
|
|
27
|
+
/**
|
|
28
|
+
* Whether another process appears to hold this profile.
|
|
29
|
+
*
|
|
30
|
+
* Returns `false` on every platform where the mechanism does not exist, which
|
|
31
|
+
* is why the caller must not read `false` as *the profile is free*. See
|
|
32
|
+
* {@link LOCK_ENTRIES}.
|
|
33
|
+
*/
|
|
34
|
+
export function profileLockLooksHeld(profileDir) {
|
|
35
|
+
for (const entry of LOCK_ENTRIES) {
|
|
36
|
+
try {
|
|
37
|
+
// `lstat`, not `stat`: the lock is a symbolic link whose target host
|
|
38
|
+
// and process need not exist, and following it would report absent for
|
|
39
|
+
// a lock that is present.
|
|
40
|
+
fs.lstatSync(path.join(profileDir, entry));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Absent, or a platform without the mechanism. Both mean "no evidence",
|
|
45
|
+
// which is not the same as "no lock".
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const REAL_FILESYSTEM = {
|
|
51
|
+
mkdirSync: (dir, options) => {
|
|
52
|
+
fs.mkdirSync(dir, options);
|
|
53
|
+
},
|
|
54
|
+
writeFileSync: (file, data) => {
|
|
55
|
+
fs.writeFileSync(file, data);
|
|
56
|
+
},
|
|
57
|
+
rmSync: (file, options) => {
|
|
58
|
+
fs.rmSync(file, options);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
function assertProfileRootWritable(profileRoot, filesystem = REAL_FILESYSTEM) {
|
|
62
|
+
try {
|
|
63
|
+
filesystem.mkdirSync(profileRoot, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The profile root could not be created, so no browser profile can be established under it: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
67
|
+
}
|
|
68
|
+
const probe = path.join(profileRoot, `.write-probe-${String(process.pid)}`);
|
|
69
|
+
try {
|
|
70
|
+
filesystem.writeFileSync(probe, '');
|
|
71
|
+
filesystem.rmSync(probe, { force: true });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The profile root is not writable, so a profile that is absent could not be created: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Establish one profile: create it if absent, use it as it is if present.
|
|
79
|
+
*
|
|
80
|
+
* **Never recreated, never cleared** — the whole of §1.2d's rule, and the
|
|
81
|
+
* reason the two branches differ only in whether a directory is made.
|
|
82
|
+
*/
|
|
83
|
+
function establishProfile(profileRoot, browser) {
|
|
84
|
+
const directory = profileDirectory(profileRoot, browser);
|
|
85
|
+
let present;
|
|
86
|
+
try {
|
|
87
|
+
present = fs.statSync(directory).isDirectory();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
present = false;
|
|
91
|
+
}
|
|
92
|
+
if (present) {
|
|
93
|
+
if (profileLockLooksHeld(directory)) {
|
|
94
|
+
// Named in plain words rather than reported as a generic launch
|
|
95
|
+
// failure, because this is exactly the case the design protects
|
|
96
|
+
// against and a caller reading it needs to know it is not a bug.
|
|
97
|
+
throw new StartupRefusal(SETUP_RULES.profileNeverDestroyed, `The ${browser} browser's profile directory exists and another process holds its lock. Setup will not recreate or clear it: that profile holds a sign-in a person established by hand, and recreating it would sign them out. Close the other browser running against this profile, or attach to it instead of starting a second one.`);
|
|
98
|
+
}
|
|
99
|
+
// Found and used exactly as it is. No inspection of its contents, no
|
|
100
|
+
// repair, no clearing — there is deliberately no branch here that could
|
|
101
|
+
// decide a profile looks wrong.
|
|
102
|
+
return { browser, disposition: 'found', relativePath: browser };
|
|
103
|
+
}
|
|
104
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
105
|
+
return { browser, disposition: 'created', relativePath: browser };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run the handshake: confirm the two browser rows, and establish both
|
|
109
|
+
* profiles.
|
|
110
|
+
*
|
|
111
|
+
* The schema has already been stepped by the time this runs — `prepareStore`
|
|
112
|
+
* does it, and `startup.schema_stepped` (§7.2) puts it before anything else
|
|
113
|
+
* happens. This confirms the result rather than repeating it.
|
|
114
|
+
*/
|
|
115
|
+
export async function runSetupHandshake(store, profileRoot, options = {}) {
|
|
116
|
+
assertProfileRootWritable(profileRoot, options.filesystem);
|
|
117
|
+
// The configured browsers, or the ones the default configuration names.
|
|
118
|
+
// **The default is a fallback for a caller that has no environment
|
|
119
|
+
// snapshot to hand, never a statement about what this installation has** —
|
|
120
|
+
// every shipped caller passes the configured lists.
|
|
121
|
+
const browsers = options.browsers ?? DEFAULT_BROWSER_IDS;
|
|
122
|
+
const schemaVersion = await store.immediate(({ db }) => {
|
|
123
|
+
// The version is stamped in the store's own header rather than in a
|
|
124
|
+
// table, so it is read the way the stepper writes it.
|
|
125
|
+
const version = db.pragma('user_version', { simple: true });
|
|
126
|
+
// **The row count is deliberately not asserted, and that is a decision
|
|
127
|
+
// rather than an omission** (`DECISIONS.md` §13i). A browser's row is
|
|
128
|
+
// created when it is first launched, not from configuration at startup,
|
|
129
|
+
// so a store holding fewer rows than this process has browsers
|
|
130
|
+
// configured is the ordinary state of a fresh installation rather than a
|
|
131
|
+
// fault — and two processes may hold different configurations, so no
|
|
132
|
+
// count is the right count for all of them.
|
|
133
|
+
//
|
|
134
|
+
// What that check was actually protecting — *is this store stepped* — is
|
|
135
|
+
// read directly from the version the stepper stamps, which is the fact
|
|
136
|
+
// it was standing in for.
|
|
137
|
+
return { value: typeof version === 'number' ? version : 0 };
|
|
138
|
+
});
|
|
139
|
+
// Outside the transaction, deliberately. Creating a directory is filesystem
|
|
140
|
+
// work and the arbitration transaction serialises every writer on the
|
|
141
|
+
// machine — holding it open across a filesystem call on a slow or contended
|
|
142
|
+
// volume would block every other caller for that duration.
|
|
143
|
+
const profiles = browsers.map((browser) => establishProfile(profileRoot, browser));
|
|
144
|
+
return {
|
|
145
|
+
profiles,
|
|
146
|
+
browserRows: [...browsers],
|
|
147
|
+
schemaVersion,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The report as a line per profile, saying which it created against which it
|
|
152
|
+
* found.
|
|
153
|
+
*
|
|
154
|
+
* §1.2d asks setup to *report which profiles it created and which it found*,
|
|
155
|
+
* and the distinction is the useful part: a profile reported as created on a
|
|
156
|
+
* machine where somebody expected a sign-in is the earliest possible warning
|
|
157
|
+
* that they are about to be asked to sign in again.
|
|
158
|
+
*/
|
|
159
|
+
export function describeSetupReport(report) {
|
|
160
|
+
return report.profiles.map((profile) => `${profile.browser}: profile ${profile.disposition} at ${profile.relativePath} (under the configured profile root)`);
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CAPTURES_BEFORE_WARNING } from "./tiers.js";
|
|
2
|
+
/**
|
|
3
|
+
* Capture accounting per claim: **a loud warning, never a refusal**
|
|
4
|
+
* (`MILESTONES.md` #33, `SCHEMA.md` §3.11, §6.2, §7.3).
|
|
5
|
+
*
|
|
6
|
+
* ── The two properties, and why each is written the way it is ───────────
|
|
7
|
+
*
|
|
8
|
+
* **1. It never refuses.** There is no code path in this file that can
|
|
9
|
+
* produce a refusal, and that is structural rather than careful: the only
|
|
10
|
+
* thing exported is a function returning a **string or nothing**. There is no
|
|
11
|
+
* boolean anything downstream could branch on to deny, no threshold parameter
|
|
12
|
+
* an operator could raise into a wall, and no error type. `SCHEMA.md` §6.2
|
|
13
|
+
* deletes the setting outright — *"No 'refuse captures after N'. Nothing is
|
|
14
|
+
* ever refused on capture grounds, and a value that could turn a warning into
|
|
15
|
+
* a wall would make that promise conditional."* And §7.3's
|
|
16
|
+
* `capture.never_refused_for_cost` is the build rule asserting that absence.
|
|
17
|
+
*
|
|
18
|
+
* The reasoning, from `DECISIONS.md` §13d, is worth carrying rather than
|
|
19
|
+
* citing: **an agent stopped mid-run on a legitimate job concludes the service
|
|
20
|
+
* is an obstacle and starts looking for a way around it.** A service that is
|
|
21
|
+
* occasionally expensive survives that; a service that is occasionally
|
|
22
|
+
* *unusable* does not.
|
|
23
|
+
*
|
|
24
|
+
* **2. The message names the cheaper alternative, and that is the mechanism
|
|
25
|
+
* rather than decoration.** `MILESTONES.md` #33: a bare *"you have taken a lot
|
|
26
|
+
* of captures"* teaches a caller to ask for a bigger budget. A warning that
|
|
27
|
+
* **names the snapshot or the evaluation answering the same question** teaches
|
|
28
|
+
* the thing the policy exists to teach. So the text below is the row, and a
|
|
29
|
+
* change that shortened it into a bare count would pass every test that only
|
|
30
|
+
* checked a warning was present — which is why the test for this names the
|
|
31
|
+
* alternative it must mention.
|
|
32
|
+
*
|
|
33
|
+
* **3. It fires on every capture past the threshold, not only the first**
|
|
34
|
+
* (§3.11), *"because a warning that appears once has scrolled away by the time
|
|
35
|
+
* it matters."*
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The warning for a capture that is past the threshold, or nothing.
|
|
39
|
+
*
|
|
40
|
+
* @param capturesTakenBefore how many captures this lease had already taken
|
|
41
|
+
* when this one was requested. Counted before rather than after, so the
|
|
42
|
+
* caller of this function does not have to know whether the current capture
|
|
43
|
+
* is included — an ambiguity that would put the boundary one either side of
|
|
44
|
+
* where §6.2 puts it depending on who read it.
|
|
45
|
+
*
|
|
46
|
+
* @returns the warning text, or `undefined` when there is nothing to say.
|
|
47
|
+
* **Never a refusal, and there is no shape here that could become one.**
|
|
48
|
+
*/
|
|
49
|
+
export function captureWarning(capturesTakenBefore) {
|
|
50
|
+
const thisCapture = capturesTakenBefore + 1;
|
|
51
|
+
if (thisCapture <= CAPTURES_BEFORE_WARNING)
|
|
52
|
+
return undefined;
|
|
53
|
+
return (`This lease has now taken ${String(thisCapture)} captures, past the point where a picture is ` +
|
|
54
|
+
`usually the expensive way to get an answer. This capture was served and always will be — ` +
|
|
55
|
+
`nothing here is ever refused on cost grounds. But if you are reading a value or checking ` +
|
|
56
|
+
`that an element is present, a snapshot or an evaluation returns it as text for a fraction ` +
|
|
57
|
+
`of what an image costs, because an image is re-read on every later turn of the ` +
|
|
58
|
+
`conversation and text is not.`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The one place an image library is reached, for the same reason `open.ts` is
|
|
2
|
+
// the one place the database driver is. Decoding and re-encoding a PNG is the
|
|
3
|
+
// only image work this service does, and keeping it behind one module is what
|
|
4
|
+
// makes the choice reversible without touching the pipeline.
|
|
5
|
+
import { PNG } from 'pngjs';
|
|
6
|
+
/** Decode an encoded PNG into pixels. */
|
|
7
|
+
export function decodePng(encoded) {
|
|
8
|
+
const png = PNG.sync.read(Buffer.from(encoded));
|
|
9
|
+
return { width: png.width, height: png.height, pixels: new Uint8Array(png.data) };
|
|
10
|
+
}
|
|
11
|
+
/** Encode pixels back into a PNG. */
|
|
12
|
+
export function encodePng(image) {
|
|
13
|
+
const png = new PNG({ width: image.width, height: image.height });
|
|
14
|
+
png.data = Buffer.from(image.pixels);
|
|
15
|
+
return new Uint8Array(PNG.sync.write(png));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* What a picture becomes when its long edge is capped.
|
|
19
|
+
*
|
|
20
|
+
* **The aspect ratio is preserved and the short edge is never rounded to
|
|
21
|
+
* zero.** A one-pixel-tall banner shrunk by a large factor would otherwise
|
|
22
|
+
* produce a zero-height image, which is not an image; the schema's
|
|
23
|
+
* `CHECK (height > 0)` would refuse the row, far away from the cause.
|
|
24
|
+
*/
|
|
25
|
+
export function scaledDimensions(width, height, longestEdge) {
|
|
26
|
+
const longest = Math.max(width, height);
|
|
27
|
+
// **Never upscale.** A picture smaller than the rung is written as it is,
|
|
28
|
+
// which is what makes `captures.width === captures.source_width` mean
|
|
29
|
+
// "nothing was shrunk" (§1.7) rather than "nothing needed to be".
|
|
30
|
+
if (longest <= longestEdge)
|
|
31
|
+
return { width, height };
|
|
32
|
+
const factor = longestEdge / longest;
|
|
33
|
+
return {
|
|
34
|
+
width: Math.max(1, Math.round(width * factor)),
|
|
35
|
+
height: Math.max(1, Math.round(height * factor)),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Shrink a picture to a target size by averaging each destination pixel over
|
|
40
|
+
* the source region it covers.
|
|
41
|
+
*
|
|
42
|
+
* A box filter rather than nearest-neighbour sampling, and the difference
|
|
43
|
+
* matters for what this service is for: nearest-neighbour throws away every
|
|
44
|
+
* source pixel but one, so a one-pixel rule or a thin line lands on a sampled
|
|
45
|
+
* pixel or vanishes entirely depending on where it happened to fall.
|
|
46
|
+
* Averaging keeps it as a fainter line, which is what a caller judging layout
|
|
47
|
+
* needs to still be able to see. It is also what makes the later diff's
|
|
48
|
+
* "smallest change reported" bound (§6.2) meaningful rather than a lottery.
|
|
49
|
+
*
|
|
50
|
+
* **Returns the same image when nothing needs shrinking**, so a caller cannot
|
|
51
|
+
* tell "already small enough" from "shrunk by a factor of one" by looking at
|
|
52
|
+
* the pixels — they are the same picture either way, which is the honest
|
|
53
|
+
* answer.
|
|
54
|
+
*/
|
|
55
|
+
export function downscale(image, longestEdge) {
|
|
56
|
+
const target = scaledDimensions(image.width, image.height, longestEdge);
|
|
57
|
+
if (target.width === image.width && target.height === image.height)
|
|
58
|
+
return image;
|
|
59
|
+
const pixels = new Uint8Array(target.width * target.height * 4);
|
|
60
|
+
const xRatio = image.width / target.width;
|
|
61
|
+
const yRatio = image.height / target.height;
|
|
62
|
+
for (let y = 0; y < target.height; y++) {
|
|
63
|
+
// The source rows this destination row covers. `Math.ceil` on the end and
|
|
64
|
+
// a floor on the start means adjacent destination pixels tile the source
|
|
65
|
+
// without gaps, so no source pixel is skipped by rounding.
|
|
66
|
+
const yStart = Math.floor(y * yRatio);
|
|
67
|
+
const yEnd = Math.min(image.height, Math.max(yStart + 1, Math.ceil((y + 1) * yRatio)));
|
|
68
|
+
for (let x = 0; x < target.width; x++) {
|
|
69
|
+
const xStart = Math.floor(x * xRatio);
|
|
70
|
+
const xEnd = Math.min(image.width, Math.max(xStart + 1, Math.ceil((x + 1) * xRatio)));
|
|
71
|
+
let red = 0;
|
|
72
|
+
let green = 0;
|
|
73
|
+
let blue = 0;
|
|
74
|
+
let alpha = 0;
|
|
75
|
+
let counted = 0;
|
|
76
|
+
for (let sourceY = yStart; sourceY < yEnd; sourceY++) {
|
|
77
|
+
for (let sourceX = xStart; sourceX < xEnd; sourceX++) {
|
|
78
|
+
const at = (sourceY * image.width + sourceX) * 4;
|
|
79
|
+
red += image.pixels[at] ?? 0;
|
|
80
|
+
green += image.pixels[at + 1] ?? 0;
|
|
81
|
+
blue += image.pixels[at + 2] ?? 0;
|
|
82
|
+
alpha += image.pixels[at + 3] ?? 0;
|
|
83
|
+
counted++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const to = (y * target.width + x) * 4;
|
|
87
|
+
pixels[to] = Math.round(red / counted);
|
|
88
|
+
pixels[to + 1] = Math.round(green / counted);
|
|
89
|
+
pixels[to + 2] = Math.round(blue / counted);
|
|
90
|
+
pixels[to + 3] = Math.round(alpha / counted);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { width: target.width, height: target.height, pixels };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A solid picture of a given size, encoded.
|
|
97
|
+
*
|
|
98
|
+
* Used by the fake driver so that a test's canned capture is something the
|
|
99
|
+
* pipeline can genuinely decode, and by tests that need a picture of known
|
|
100
|
+
* dimensions. It lives here rather than in the fake because the fake must not
|
|
101
|
+
* be the only thing that knows how to make one.
|
|
102
|
+
*/
|
|
103
|
+
export function solidPng(width, height, colour = [255, 255, 255, 255]) {
|
|
104
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
105
|
+
for (let at = 0; at < pixels.length; at += 4) {
|
|
106
|
+
pixels[at] = colour[0];
|
|
107
|
+
pixels[at + 1] = colour[1];
|
|
108
|
+
pixels[at + 2] = colour[2];
|
|
109
|
+
pixels[at + 3] = colour[3];
|
|
110
|
+
}
|
|
111
|
+
return encodePng({ width, height, pixels });
|
|
112
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolution-ladder harness (`MILESTONES.md` #34).
|
|
3
|
+
*
|
|
4
|
+
* Sweeps one picture down a ladder of longest-edge caps and records, per rung,
|
|
5
|
+
* what the study needs to compare rungs against each other: **the dimensions,
|
|
6
|
+
* the bytes, the downscale ratio, and the estimated token cost.**
|
|
7
|
+
*
|
|
8
|
+
* ── It measures the pipeline that ships, not a better one ───────────────
|
|
9
|
+
*
|
|
10
|
+
* Every rung here goes through {@link downscale} and {@link encodePng} — the
|
|
11
|
+
* same box filter and the same encoder the capture pipeline uses. That is the
|
|
12
|
+
* point rather than an economy: `image.ts` names its resampler's cost outright
|
|
13
|
+
* (box averaging is softer than a bicubic or Lanczos filter), and a study run
|
|
14
|
+
* on a hypothetical better resampler would settle rungs this service does not
|
|
15
|
+
* have. **The numbers this produces are only claims about this pipeline.**
|
|
16
|
+
*
|
|
17
|
+
* ── Where the fields come from ──────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* `bytes` is the length of the re-encoded PNG, which is what `captures.bytes`
|
|
20
|
+
* records (§1.7) and therefore comparable with a real capture's telemetry.
|
|
21
|
+
* `estimatedTokens` comes from {@link estimateTokens} — the formula §6.4 fixes
|
|
22
|
+
* by the version, taking dimensions and nothing else, so a rung's cost here is
|
|
23
|
+
* computed exactly as a live capture's is.
|
|
24
|
+
*
|
|
25
|
+
* **This harness reaches no store and no browser.** It takes decoded pixels and
|
|
26
|
+
* returns numbers, which is what lets the study run its arithmetic rungs in
|
|
27
|
+
* continuous integration with no browser binary while the rendered-text rungs
|
|
28
|
+
* skip.
|
|
29
|
+
*/
|
|
30
|
+
import { downscale, encodePng, scaledDimensions } from "./image.js";
|
|
31
|
+
import { estimateTokens } from "./tiers.js";
|
|
32
|
+
/**
|
|
33
|
+
* Sweep one picture down a ladder of caps.
|
|
34
|
+
*
|
|
35
|
+
* The rungs are given rather than read from {@link TIER_LONGEST_EDGE}, because
|
|
36
|
+
* a study that could only measure the three numbers already shipped could never
|
|
37
|
+
* discover that a fourth is better. The shipped rungs are one input among
|
|
38
|
+
* several.
|
|
39
|
+
*/
|
|
40
|
+
export function sweepLadder(source, rungs) {
|
|
41
|
+
return rungs.map((longestEdge) => {
|
|
42
|
+
const shrunk = downscale(source, longestEdge);
|
|
43
|
+
const target = scaledDimensions(source.width, source.height, longestEdge);
|
|
44
|
+
const longest = Math.max(source.width, source.height);
|
|
45
|
+
return {
|
|
46
|
+
longestEdge,
|
|
47
|
+
width: shrunk.width,
|
|
48
|
+
height: shrunk.height,
|
|
49
|
+
bytes: encodePng(shrunk).length,
|
|
50
|
+
// Guarded against a zero-size source, which is not an image but is also
|
|
51
|
+
// not this function's business to refuse.
|
|
52
|
+
ratio: longest === 0 ? 1 : Math.min(1, target.width / Math.max(1, source.width)),
|
|
53
|
+
estimatedTokens: estimateTokens(shrunk.width, shrunk.height),
|
|
54
|
+
image: shrunk,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Render a sweep as a table, for the study's own report.
|
|
60
|
+
*
|
|
61
|
+
* Plain text rather than anything structured: this exists so a measurement can
|
|
62
|
+
* be **read** in a pull request body and checked against the assertions that
|
|
63
|
+
* quote it, which is the whole difference between publishing evidence and
|
|
64
|
+
* publishing numbers.
|
|
65
|
+
*/
|
|
66
|
+
export function formatLadder(rungs) {
|
|
67
|
+
const header = '| long edge | dimensions | ratio | bytes | est. tokens |';
|
|
68
|
+
const rule = '|---|---|---|---|---|';
|
|
69
|
+
const rows = rungs.map((rung) => `| ${String(rung.longestEdge)} | ${String(rung.width)}x${String(rung.height)} | ` +
|
|
70
|
+
`${rung.ratio.toFixed(3)} | ${String(rung.bytes)} | ${String(rung.estimatedTokens)} |`);
|
|
71
|
+
return [header, rule, ...rows].join('\n');
|
|
72
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Objective, OCR-free proxies for what survives a downscale — the instruments
|
|
3
|
+
* the resolution study (`MILESTONES.md` #34) measures the rungs with.
|
|
4
|
+
*
|
|
5
|
+
* ── Why proxies, and what they are honestly worth ───────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The claim the tiers rest on is a claim about **a reader**: *text legibility
|
|
8
|
+
* breaks at a higher resolution than layout critique does.* Settling that
|
|
9
|
+
* directly would mean asking somebody whether they can read a picture, at
|
|
10
|
+
* every rung, on every page — a judgement no test can make and no assertion
|
|
11
|
+
* can hold.
|
|
12
|
+
*
|
|
13
|
+
* So this module does not attempt it. It measures **physical properties of the
|
|
14
|
+
* pixels** that legibility depends on, and it is deliberate that each one is
|
|
15
|
+
* arithmetic over an array rather than an opinion:
|
|
16
|
+
*
|
|
17
|
+
* - {@link strokeContrast} — how much of a glyph's ink survives. A stroke
|
|
18
|
+
* thinner than a destination pixel is averaged into its background by the
|
|
19
|
+
* box filter, and what is left is a fainter mark on a lighter field. This
|
|
20
|
+
* reports how far the darkest ink moved toward the page.
|
|
21
|
+
* - {@link featureSurvives} — whether a **known** one-pixel feature is still
|
|
22
|
+
* distinguishable from the field around it at all. This is the layout-side
|
|
23
|
+
* instrument: a hairline rule, a border, a focus ring.
|
|
24
|
+
* - {@link countDistinctRuns} — how many separate dark runs remain along a
|
|
25
|
+
* line. Two glyphs that merge into one blob have lost the gap between them,
|
|
26
|
+
* and a merged gap is the mechanism by which text stops being readable
|
|
27
|
+
* before it stops being visible.
|
|
28
|
+
*
|
|
29
|
+
* ── ⚠️ WHAT THESE DO NOT ESTABLISH — read before quoting a number ───────
|
|
30
|
+
*
|
|
31
|
+
* **None of these is a legibility measurement, and no number here should be
|
|
32
|
+
* reported as one.** A proxy presented as an answer is worse than an honest
|
|
33
|
+
* gap. Specifically:
|
|
34
|
+
*
|
|
35
|
+
* 1. **A surviving stroke is not a readable glyph.** {@link strokeContrast}
|
|
36
|
+
* can report healthy contrast on a letter whose distinguishing feature —
|
|
37
|
+
* the gap that separates an `e` from a `c` — has closed. Contrast is
|
|
38
|
+
* necessary for legibility and nowhere near sufficient.
|
|
39
|
+
* 2. **These say nothing about a model's vision.** What an agent looking at a
|
|
40
|
+
* picture can actually resolve is a property of that model, not of the
|
|
41
|
+
* pixels. These measure what the *pipeline* destroys, which bounds what any
|
|
42
|
+
* reader could recover but does not predict what one will.
|
|
43
|
+
* 3. **They are measured on synthetic marks of known geometry**, not on
|
|
44
|
+
* rendered prose. That is what makes them arithmetic rather than opinion,
|
|
45
|
+
* and it is also why they cannot speak to font hinting, subpixel rendering
|
|
46
|
+
* or the shapes of real letterforms.
|
|
47
|
+
*
|
|
48
|
+
* They are, within those limits, enough to answer the *comparative* question
|
|
49
|
+
* the study asks — whether the resolution at which fine text detail is
|
|
50
|
+
* destroyed is higher than the one at which a layout-scale feature is — because
|
|
51
|
+
* both sides are measured with the same instrument on the same ladder.
|
|
52
|
+
*
|
|
53
|
+
* ── This is orthogonal to the differ's tolerance ────────────────────────
|
|
54
|
+
*
|
|
55
|
+
* A separate measured limit exists in this repository: at the default
|
|
56
|
+
* tolerance a change fainter than roughly grey 225 on white is not reported.
|
|
57
|
+
* **That is a property of the comparison, not of resolution** — it says what
|
|
58
|
+
* the differ can see, and this module says what the downscale leaves behind.
|
|
59
|
+
* They are different questions and their numbers do not combine.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Whether a row is actually on the picture.
|
|
63
|
+
*
|
|
64
|
+
* **A row off the end must refuse rather than read as blank.** A scan over a
|
|
65
|
+
* row that does not exist finds no ink, and "no ink" is indistinguishable from
|
|
66
|
+
* "the mark was averaged away" — so an off-by-one in a caller's row arithmetic
|
|
67
|
+
* would report a *destroyed feature* instead of a mistake, which is the study
|
|
68
|
+
* measuring nothing and saying so confidently. This was a real defect here: a
|
|
69
|
+
* shrink that moved a mark off the sampled row reported full contrast from an
|
|
70
|
+
* empty scan.
|
|
71
|
+
*/
|
|
72
|
+
function requireRow(image, row) {
|
|
73
|
+
if (!Number.isInteger(row) || row < 0 || row >= image.height) {
|
|
74
|
+
throw new RangeError(`row ${String(row)} is not on a picture ${String(image.width)}x${String(image.height)}: an off-picture row finds no ink, which would be misread as a destroyed feature`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Luminance of one pixel, 0 (black) to 255 (white). */
|
|
78
|
+
function luminanceAt(image, x, y) {
|
|
79
|
+
const at = (y * image.width + x) * 4;
|
|
80
|
+
const red = image.pixels[at] ?? 0;
|
|
81
|
+
const green = image.pixels[at + 1] ?? 0;
|
|
82
|
+
const blue = image.pixels[at + 2] ?? 0;
|
|
83
|
+
// Rec. 601 luma. The weights matter for coloured text; for the black-on-white
|
|
84
|
+
// marks this study uses, any reasonable weighting gives the same answer.
|
|
85
|
+
return 0.299 * red + 0.587 * green + 0.114 * blue;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* How dark the darkest ink on a row still is, as a fraction of the original
|
|
89
|
+
* ink-to-background contrast.
|
|
90
|
+
*
|
|
91
|
+
* `1` means the mark survived at full strength; `0` means it was averaged away
|
|
92
|
+
* into the background entirely. A thin stroke shrunk past the point where it
|
|
93
|
+
* covers a whole destination pixel loses contrast in proportion to the fraction
|
|
94
|
+
* of that pixel it covers, which is exactly what a box filter does and exactly
|
|
95
|
+
* what this reports.
|
|
96
|
+
*
|
|
97
|
+
* @param background the field's luminance, which the mark is measured against.
|
|
98
|
+
* @param ink the mark's original luminance, before any shrinking.
|
|
99
|
+
*/
|
|
100
|
+
export function strokeContrast(image, row, background, ink) {
|
|
101
|
+
requireRow(image, row);
|
|
102
|
+
const full = Math.abs(background - ink);
|
|
103
|
+
if (full === 0)
|
|
104
|
+
return 0;
|
|
105
|
+
// The pixel on this row furthest from the background is the surviving ink.
|
|
106
|
+
let furthest = 0;
|
|
107
|
+
for (let x = 0; x < image.width; x++) {
|
|
108
|
+
const distance = Math.abs(luminanceAt(image, x, row) - background);
|
|
109
|
+
if (distance > furthest)
|
|
110
|
+
furthest = distance;
|
|
111
|
+
}
|
|
112
|
+
return Math.min(1, furthest / full);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Whether a known feature is still distinguishable from its background.
|
|
116
|
+
*
|
|
117
|
+
* **The threshold is stated as an argument rather than chosen here**, because
|
|
118
|
+
* "distinguishable" is the thing under study: a caller sweeping the ladder
|
|
119
|
+
* decides what fraction of the original contrast it is willing to call
|
|
120
|
+
* survival, and the sweep reports the answer at each. A constant baked in here
|
|
121
|
+
* would be this module quietly deciding the study's result.
|
|
122
|
+
*/
|
|
123
|
+
export function featureSurvives(image, row, background, ink, minimumContrast) {
|
|
124
|
+
return strokeContrast(image, row, background, ink) >= minimumContrast;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The strongest measurement anywhere in the picture, rather than on a row a
|
|
128
|
+
* caller had to predict.
|
|
129
|
+
*
|
|
130
|
+
* **This exists because mapping a source row to a destination row is not a
|
|
131
|
+
* measurement, it is a guess.** A mark 24 source pixels tall shrunk by 0.32
|
|
132
|
+
* lands across some 7 destination rows, and which of them carries the most ink
|
|
133
|
+
* depends on where the box filter's boundaries fell — so a study that computed
|
|
134
|
+
* `sourceRow * ratio` and read that one row would report a number that swings
|
|
135
|
+
* with rounding rather than with resolution. Scanning every row and taking the
|
|
136
|
+
* best removes that artefact entirely: the answer is *"the most that survived
|
|
137
|
+
* anywhere"*, which is the generous reading and therefore the honest one for a
|
|
138
|
+
* claim about what is destroyed.
|
|
139
|
+
*
|
|
140
|
+
* Returns the best run count and the contrast on the row that achieved it.
|
|
141
|
+
*/
|
|
142
|
+
export function bestSurvivingRow(image, background, ink, minimumContrast) {
|
|
143
|
+
let runs = 0;
|
|
144
|
+
let contrast = 0;
|
|
145
|
+
let row = 0;
|
|
146
|
+
for (let y = 0; y < image.height; y++) {
|
|
147
|
+
const here = countDistinctRuns(image, y, background, ink, minimumContrast);
|
|
148
|
+
if (here > runs) {
|
|
149
|
+
runs = here;
|
|
150
|
+
contrast = strokeContrast(image, y, background, ink);
|
|
151
|
+
row = y;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// A picture whose marks all fell below the ink threshold has no run anywhere;
|
|
155
|
+
// report the strongest contrast that survived, which is the fading a hairline
|
|
156
|
+
// shows before it disappears.
|
|
157
|
+
if (runs === 0) {
|
|
158
|
+
for (let y = 0; y < image.height; y++) {
|
|
159
|
+
const here = strokeContrast(image, y, background, ink);
|
|
160
|
+
if (here > contrast) {
|
|
161
|
+
contrast = here;
|
|
162
|
+
row = y;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { runs, contrast, row };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* How many separate dark runs remain along a row.
|
|
170
|
+
*
|
|
171
|
+
* This is the gap-closing instrument. A row of alternating one-pixel ink and
|
|
172
|
+
* one-pixel space has as many runs as it has marks; once the shrink averages
|
|
173
|
+
* neighbouring marks together the runs merge, and the count falls. **The count
|
|
174
|
+
* falling is the mechanism by which text becomes unreadable while remaining
|
|
175
|
+
* visible** — the ink is still there, the spaces between letters are not.
|
|
176
|
+
*
|
|
177
|
+
* A pixel counts as ink when it is at least `minimumContrast` of the way from
|
|
178
|
+
* the background toward the original ink, using the same scale
|
|
179
|
+
* {@link strokeContrast} reports on.
|
|
180
|
+
*/
|
|
181
|
+
export function countDistinctRuns(image, row, background, ink, minimumContrast) {
|
|
182
|
+
requireRow(image, row);
|
|
183
|
+
const full = Math.abs(background - ink);
|
|
184
|
+
if (full === 0)
|
|
185
|
+
return 0;
|
|
186
|
+
let runs = 0;
|
|
187
|
+
let inRun = false;
|
|
188
|
+
for (let x = 0; x < image.width; x++) {
|
|
189
|
+
const isInk = Math.abs(luminanceAt(image, x, row) - background) / full >= minimumContrast;
|
|
190
|
+
if (isInk && !inRun)
|
|
191
|
+
runs++;
|
|
192
|
+
inRun = isInk;
|
|
193
|
+
}
|
|
194
|
+
return runs;
|
|
195
|
+
}
|