@nextcommerce/campaigns-os 1.60.0 → 1.61.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/CHANGELOG.md +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/stage-record.mjs
CHANGED
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
// stylesheets must also match the built output byte for byte).
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
26
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
|
|
29
29
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
30
30
|
|
|
31
|
-
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
31
|
+
import { BRAND_FONTS_FILENAME, BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
32
32
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
33
33
|
import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
34
34
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
@@ -127,7 +127,7 @@ function typeName(value) {
|
|
|
127
127
|
|
|
128
128
|
// visual_review keys only `polish capture` writes. `record polish --evidence`
|
|
129
129
|
// refuses them by name and carries the captured values forward.
|
|
130
|
-
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
|
|
130
|
+
export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability", "captures", "element_parity"]);
|
|
131
131
|
export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
|
|
132
132
|
|
|
133
133
|
export function refuseRecord(stage, problems) {
|
|
@@ -512,10 +512,24 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
|
512
512
|
// family components, so it must load a brand layer (brand-theme.css or
|
|
513
513
|
// checkout-brand.css) after it, and that file must be in the built output. A
|
|
514
514
|
// page that loads neither renders the design's own markup and needs no brand
|
|
515
|
-
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs").
|
|
515
|
+
// layer (docs/brand-theme-bridge.md, "Where next-core.css belongs"). When
|
|
516
|
+
// theme generate wrote brand-fonts.css (the design's own faces), each styled
|
|
517
|
+
// page must load the campaign's copy too, or the families brand-theme.css
|
|
518
|
+
// names fall back to the system font. Where it was written is the Build
|
|
519
|
+
// Context's theme.generated.fonts_css_path (theme generate --out-dir moves
|
|
520
|
+
// it); the default path is read only when the context names none. Any page
|
|
516
521
|
// that breaks the rule is a refusal naming the page.
|
|
517
|
-
function brandLayerFacts(doctor) {
|
|
522
|
+
function brandLayerFacts(doctor, context = null) {
|
|
518
523
|
const derived = doctor.derived || {};
|
|
524
|
+
const recordedFonts = context?.theme?.generated?.fonts_css_path;
|
|
525
|
+
const generatedFonts = typeof recordedFonts === "string" && recordedFonts
|
|
526
|
+
? resolve(derived.target_repo, recordedFonts)
|
|
527
|
+
: (context?.theme?.generated ? null : resolve(derived.target_repo, ".campaign-runtime/theme", BRAND_FONTS_FILENAME));
|
|
528
|
+
const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
|
|
529
|
+
const insideSite = (file) => {
|
|
530
|
+
const rel = relative(site.site_root, file);
|
|
531
|
+
return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel);
|
|
532
|
+
};
|
|
519
533
|
const commerce = commerceScopeFromScope(derived.scope);
|
|
520
534
|
if (!commerce.all.length) {
|
|
521
535
|
throw refuseRecord("theme", ["The campaign ships no commerce pages, so there is no brand layer to record; the theme gate does not apply."]);
|
|
@@ -566,6 +580,30 @@ function brandLayerFacts(doctor) {
|
|
|
566
580
|
problems.push(`${label}: ${builtRel} links ${hrefs[brand]}, which is not in the built output.`);
|
|
567
581
|
continue;
|
|
568
582
|
}
|
|
583
|
+
// brand-fonts.css holds only @font-face rules, so unlike the brand layer
|
|
584
|
+
// its position relative to next-core.css does not matter.
|
|
585
|
+
if (fontsRequired) {
|
|
586
|
+
const fontsHref = hrefs.find((candidate) => hrefName(candidate) === BRAND_FONTS_FILENAME);
|
|
587
|
+
const bare = fontsHref ? fontsHref.split(/[?#]/)[0] : null;
|
|
588
|
+
const fontsFile = bare && !/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(bare)
|
|
589
|
+
? (bare.startsWith("/") ? join(site.site_root, bare) : resolve(dirname(built.built_path), bare))
|
|
590
|
+
: null;
|
|
591
|
+
const generatedRel = relative(derived.target_repo, generatedFonts);
|
|
592
|
+
const problem = !fontsHref
|
|
593
|
+
? `${label}: ${builtRel} links no ${BRAND_FONTS_FILENAME}. Theme generate wrote the design's font faces to ${generatedRel}; copy it into the campaign assets/css folder beside the brand layer, list it in the page's frontmatter styles and rebuild.`
|
|
594
|
+
: !fontsFile || !insideSite(fontsFile)
|
|
595
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not a file in this campaign's built output; link the campaign's own copy of ${generatedRel}.`
|
|
596
|
+
: !existsSync(fontsFile)
|
|
597
|
+
? `${label}: ${builtRel} links ${fontsHref}, which is not in the built output.`
|
|
598
|
+
: readFileSync(fontsFile, "utf8") !== readFileSync(generatedFonts, "utf8")
|
|
599
|
+
? `${label}: ${builtRel} links ${fontsHref}, which differs from the generated ${generatedRel}; copy the generated file and rebuild.`
|
|
600
|
+
: null;
|
|
601
|
+
if (problem) {
|
|
602
|
+
problems.push(problem);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, fontsFile)}, identical to the generated ${generatedRel}.`);
|
|
606
|
+
}
|
|
569
607
|
layers.push(file);
|
|
570
608
|
styled.push(label);
|
|
571
609
|
evidence.push(`${label}: ${builtRel} loads ${relative(derived.target_repo, file)} after ${CORE_STYLESHEET}.`);
|
|
@@ -865,7 +903,7 @@ function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
|
865
903
|
// over the report this record would write. A blocked or skipped Polish is
|
|
866
904
|
// not a pass the gate could grant; doctor keeps QA blocked on it.
|
|
867
905
|
const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
|
|
868
|
-
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
|
|
906
|
+
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint, newEvidence: true });
|
|
869
907
|
if (gate.status === "blocked") {
|
|
870
908
|
problems.push(`${gate.code}: ${gate.reason}`);
|
|
871
909
|
for (const problem of gate.problems || []) problems.push(problem);
|
|
@@ -1180,7 +1218,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1180
1218
|
}
|
|
1181
1219
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
1182
1220
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1183
|
-
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
1221
|
+
if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
|
|
1184
1222
|
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1185
1223
|
const next = stage === "setup"
|
|
1186
1224
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
package/src/theme-gate.mjs
CHANGED
|
@@ -166,13 +166,13 @@ export function evaluateThemeGate({ reportTheme = null, contextTheme = null, sco
|
|
|
166
166
|
id: "theme_generate",
|
|
167
167
|
kind: "command",
|
|
168
168
|
command: `campaigns-os theme generate --packet ${packetArg}`,
|
|
169
|
-
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens.",
|
|
169
|
+
description: "Generate .campaign-runtime/theme/brand-theme.css from the inspected source tokens, and brand-fonts.css beside it when the design source ships the faces its headings, body copy or buttons use.",
|
|
170
170
|
},
|
|
171
171
|
{
|
|
172
172
|
id: "apply_brand_layer",
|
|
173
173
|
kind: "manual",
|
|
174
174
|
command: null,
|
|
175
|
-
description: `Copy brand-theme.css into the campaign assets/css folder, list
|
|
175
|
+
description: `Copy brand-theme.css (and brand-fonts.css, when theme generate wrote one) into the campaign assets/css folder, list brand-theme.css after next-core.css, and brand-fonts.css anywhere, in the frontmatter styles of the commerce pages that render family components, rebuild, run campaigns-os record build, then campaigns-os record theme --packet ${packetArg}, which records report.theme from the built pages.`,
|
|
176
176
|
},
|
|
177
177
|
{
|
|
178
178
|
id: "waive_theme",
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { boundedDimensions, VISUAL_MAX_DECODE_PIXELS } from "./png-header.mjs";
|
|
5
|
+
|
|
6
|
+
const own = (value, key) => value !== null && ["object", "function"].includes(typeof value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
7
|
+
const boundedArea = boundedDimensions;
|
|
8
|
+
function geometry(image) {
|
|
9
|
+
boundedArea(own(image, "width"), own(image, "height"));
|
|
10
|
+
if (!ArrayBuffer.isView(image.data) || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
|
|
11
|
+
}
|
|
12
|
+
const track = (memory, data) => { memory?.acquire(data); return data; };
|
|
13
|
+
function release(image, memory) {
|
|
14
|
+
const data = image.data;
|
|
15
|
+
image.data = null;
|
|
16
|
+
if (data) memory?.release(data);
|
|
17
|
+
}
|
|
18
|
+
const allocate = (length, memory) => track(memory, Buffer.alloc(length));
|
|
19
|
+
function maskRects(rects) {
|
|
20
|
+
if (!Array.isArray(rects) || rects.some((r) => !r || ![r.x, r.y].every(Number.isFinite)
|
|
21
|
+
|| ![r.width, r.height].every((n) => Number.isFinite(n) && n >= 0))) throw new RangeError("invalid_mask");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Comparisons consume their RGBA inputs. Callbacks must finish encoding/writing
|
|
25
|
+
// artifacts synchronously; only metrics survive into the next comparison.
|
|
26
|
+
export function readRgba(source, memory) {
|
|
27
|
+
boundedArea(own(source, "width"), own(source, "height"));
|
|
28
|
+
if (typeof own(source, "load") !== "function") { geometry(source); return source; }
|
|
29
|
+
const image = source.load();
|
|
30
|
+
geometry(image);
|
|
31
|
+
if (image.width !== source.width || image.height !== source.height) throw new RangeError("invalid_image_geometry");
|
|
32
|
+
track(memory, image.data); return image;
|
|
33
|
+
}
|
|
34
|
+
export function releaseComparison(part, memory) {
|
|
35
|
+
for (const key of ["basis_crop", "build_crop", "heatmap"]) if (part?.[key]) {
|
|
36
|
+
release(part[key], memory); delete part[key];
|
|
37
|
+
}
|
|
38
|
+
delete part.mismatches; delete part.mask;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DIFF_OPTIONS = Object.freeze({ threshold: 0.1, include_aa: false, alpha: 0.1 });
|
|
42
|
+
// Neither package exports its version: read it from the package.json above the
|
|
43
|
+
// resolved entry whose name is the package's. Unknown is null, never a guess.
|
|
44
|
+
export function installedPackageVersion(name, { resolveEntry = (specifier) => import.meta.resolve(specifier) } = {}) {
|
|
45
|
+
try {
|
|
46
|
+
let directory = dirname(fileURLToPath(resolveEntry(name)));
|
|
47
|
+
for (;;) {
|
|
48
|
+
const file = join(directory, "package.json");
|
|
49
|
+
if (existsSync(file)) {
|
|
50
|
+
const manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
if (manifest?.name === name) return typeof manifest.version === "string" ? manifest.version : null;
|
|
52
|
+
}
|
|
53
|
+
const parent = dirname(directory);
|
|
54
|
+
if (parent === directory) return null;
|
|
55
|
+
directory = parent;
|
|
56
|
+
}
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
export async function loadVisualDependencies({ importer = (name) => import(name), versionOf = installedPackageVersion } = {}) {
|
|
60
|
+
async function load(name, type) {
|
|
61
|
+
try {
|
|
62
|
+
const module = await importer(name);
|
|
63
|
+
const api = own(module, "default") || module;
|
|
64
|
+
const result = { status: "available", version: own(module, "version") ?? own(api, "version") ?? versionOf(name), package: name };
|
|
65
|
+
if (type === "decoder") {
|
|
66
|
+
const PNG = own(module, "PNG") || own(api, "PNG");
|
|
67
|
+
const sync = own(PNG, "sync");
|
|
68
|
+
if (typeof own(sync, "read") !== "function" || typeof own(sync, "write") !== "function") throw new TypeError("Invalid PNG module");
|
|
69
|
+
result.decode = (bytes) => PNG.sync.read(bytes);
|
|
70
|
+
result.encode = (image) => PNG.sync.write(image);
|
|
71
|
+
} else {
|
|
72
|
+
if (typeof api !== "function") throw new TypeError("Invalid diff module");
|
|
73
|
+
result.compare = api;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A missing transitive import is a broken installation, not this package being absent.
|
|
78
|
+
const missing = ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"].includes(error?.code)
|
|
79
|
+
&& new RegExp(`(?:package|module) ['\"]${name}['\"]`).test(error.message || "");
|
|
80
|
+
return { status: missing ? "missing" : "failed", version: null, package: name,
|
|
81
|
+
install_hint: `Install ${name} where campaigns-os is installed (npm install ${name}).` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const [decoder, diff] = await Promise.all([load("pngjs", "decoder"), load("pixelmatch", "diff")]);
|
|
85
|
+
return { decoder, diff };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padRgba(image, width, height, color = [255, 255, 255, 255], memory) {
|
|
89
|
+
boundedArea(width, height);
|
|
90
|
+
geometry(image);
|
|
91
|
+
if (width < image.width || height < image.height || image.data.length !== image.width * image.height * 4) throw new RangeError("invalid_image_geometry");
|
|
92
|
+
const data = allocate(width * height * 4, memory);
|
|
93
|
+
for (let i = 0; i < data.length; i += 4) data.set(color, i);
|
|
94
|
+
for (let y = 0; y < image.height; y++) data.set(image.data.subarray(y * image.width * 4, (y + 1) * image.width * 4), y * width * 4);
|
|
95
|
+
return { width, height, data };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function paintMaskUnion(images, rectsA = [], rectsB = [], color = [255, 0, 255, 255]) {
|
|
99
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
100
|
+
if (typeof color === "string" && /^#[a-f0-9]{6}$/i.test(color)) color = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16)).concat(255);
|
|
101
|
+
if (!images.length) return { masked_pixels: 0, mask: new Uint8Array() };
|
|
102
|
+
for (const image of images) geometry(image);
|
|
103
|
+
const { width, height } = images[0];
|
|
104
|
+
boundedArea(width, height);
|
|
105
|
+
if (images.some((image) => image.width !== width || image.height !== height)) throw new RangeError("mask_canvas_mismatch");
|
|
106
|
+
const mask = new Uint8Array(width * height);
|
|
107
|
+
let masked_pixels = 0;
|
|
108
|
+
for (const rect of [...rectsA, ...rectsB]) {
|
|
109
|
+
if (!rect.width || !rect.height) continue;
|
|
110
|
+
const x0 = Math.max(0, Math.floor(rect.x)), y0 = Math.max(0, Math.floor(rect.y));
|
|
111
|
+
const x1 = Math.min(width, Math.ceil(rect.x + rect.width)), y1 = Math.min(height, Math.ceil(rect.y + rect.height));
|
|
112
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
113
|
+
const index = y * width + x;
|
|
114
|
+
if (!mask[index]) { mask[index] = 1; masked_pixels++; }
|
|
115
|
+
for (const image of images) image.data.set(color, index * 4);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { masked_pixels, mask };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function compareImages(basis, build, { diff, rectsA = [], rectsB = [], dpr = 1, memory } = {}) {
|
|
122
|
+
geometry(basis); geometry(build);
|
|
123
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
124
|
+
if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
|
|
125
|
+
const compare = typeof diff === "function" ? diff : own(diff, "compare");
|
|
126
|
+
if (typeof compare !== "function") throw new Error("dependency_missing");
|
|
127
|
+
const width = Math.max(basis.width, build.width), height = Math.max(basis.height, build.height);
|
|
128
|
+
boundedArea(width, height);
|
|
129
|
+
const originalA = { width: basis.width, height: basis.height }, originalB = { width: build.width, height: build.height };
|
|
130
|
+
track(memory, basis.data); track(memory, build.data);
|
|
131
|
+
const inputs = [{ ...basis }, { ...build }];
|
|
132
|
+
basis.data = null; build.data = null;
|
|
133
|
+
const consume = (image) => {
|
|
134
|
+
if (image.width === width && image.height === height) {
|
|
135
|
+
const result = { ...image }; image.data = null; return result;
|
|
136
|
+
}
|
|
137
|
+
const padded = padRgba(image, width, height, undefined, memory);
|
|
138
|
+
release(image, memory); return padded;
|
|
139
|
+
};
|
|
140
|
+
let a, b, heatmap, completed = false;
|
|
141
|
+
try {
|
|
142
|
+
if (inputs[0].data.buffer === inputs[1].data.buffer
|
|
143
|
+
&& inputs[0].data.byteOffset < inputs[1].data.byteOffset + inputs[1].data.byteLength
|
|
144
|
+
&& inputs[1].data.byteOffset < inputs[0].data.byteOffset + inputs[0].data.byteLength) {
|
|
145
|
+
const original = inputs[0].data;
|
|
146
|
+
inputs[0].data = allocate(original.length, memory);
|
|
147
|
+
inputs[0].data.set(original);
|
|
148
|
+
if (original !== inputs[1].data) memory?.release(original);
|
|
149
|
+
}
|
|
150
|
+
a = consume(inputs[0]); b = consume(inputs[1]);
|
|
151
|
+
const { mask, masked_pixels } = paintMaskUnion([a, b], rectsA, rectsB);
|
|
152
|
+
heatmap = { width, height, data: allocate(width * height * 4, memory) };
|
|
153
|
+
compare(a.data, b.data, heatmap.data, width, height,
|
|
154
|
+
{ threshold: DIFF_OPTIONS.threshold, includeAA: DIFF_OPTIONS.include_aa, alpha: DIFF_OPTIONS.alpha });
|
|
155
|
+
const mismatches = new Uint8Array(width * height);
|
|
156
|
+
let mismatch_pixels = 0;
|
|
157
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
158
|
+
const i = y * width + x, p = i * 4;
|
|
159
|
+
const missing = (x >= originalA.width || y >= originalA.height) !== (x >= originalB.width || y >= originalB.height);
|
|
160
|
+
if (!mask[i] && (missing || (heatmap.data[p] === 255 && heatmap.data[p + 1] === 0 && heatmap.data[p + 2] === 0))) {
|
|
161
|
+
mismatches[i] = 1; mismatch_pixels++;
|
|
162
|
+
heatmap.data.set([255, 0, 0, 255], p);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const compared_pixels = width * height - masked_pixels;
|
|
166
|
+
const unpad = (image, original) => {
|
|
167
|
+
for (let y = 0; y < original.height; y++) image.data.copyWithin(y * original.width * 4, y * width * 4, (y * width + original.width) * 4);
|
|
168
|
+
return { ...original, data: image.data.subarray(0, original.width * original.height * 4) };
|
|
169
|
+
};
|
|
170
|
+
const result = { status: "compared", reason: null, width_delta_px: originalB.width - originalA.width,
|
|
171
|
+
height_delta_px: originalB.height - originalA.height, canvas: { width, height }, masked_pixels,
|
|
172
|
+
mismatch_pixels, compared_pixels, mismatch_ratio: compared_pixels ? mismatch_pixels / compared_pixels : 0,
|
|
173
|
+
heatmap, basis_crop: unpad(a, originalA), build_crop: unpad(b, originalB), mismatches, mask,
|
|
174
|
+
worst_tiles: worstTiles({ width, height, mismatches, mask }, { tile: Math.min(32 * dpr, Math.max(width, height)) }) };
|
|
175
|
+
completed = true; return result;
|
|
176
|
+
} finally {
|
|
177
|
+
if (!completed) for (const image of [a, b, heatmap, ...inputs]) if (image?.data) release(image, memory);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function worstTiles({ width, height, mismatches, mask = [] }, { tile = 32, count = 3 } = {}) {
|
|
182
|
+
boundedArea(width, height);
|
|
183
|
+
if (!Number.isFinite(tile) || tile <= 0 || !Number.isSafeInteger(count) || count < 0) throw new RangeError("invalid_tile_options");
|
|
184
|
+
tile = Math.max(1, Math.round(tile));
|
|
185
|
+
count = Math.min(count, 3);
|
|
186
|
+
if (!mismatches || mismatches.length !== width * height || (mask.length && mask.length !== width * height)) throw new RangeError("invalid_tile_geometry");
|
|
187
|
+
const tiles = [];
|
|
188
|
+
for (let y = 0; y < height; y += tile) for (let x = 0; x < width; x += tile) {
|
|
189
|
+
const w = Math.min(tile, width - x), h = Math.min(tile, height - y);
|
|
190
|
+
let mismatch = 0, compared = 0;
|
|
191
|
+
for (let row = y; row < y + h; row++) for (let col = x; col < x + w; col++) {
|
|
192
|
+
const i = row * width + col;
|
|
193
|
+
if (!mask[i]) { compared++; mismatch += mismatches[i]; }
|
|
194
|
+
}
|
|
195
|
+
tiles.push({ x, y, width: w, height: h, mismatch_ratio: compared ? mismatch / compared : 0 });
|
|
196
|
+
tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x);
|
|
197
|
+
if (tiles.length > count) tiles.pop();
|
|
198
|
+
}
|
|
199
|
+
return tiles.sort((a, b) => b.mismatch_ratio - a.mismatch_ratio || a.y - b.y || a.x - b.x).slice(0, count);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function encodeHeatmap(image, decoder) {
|
|
203
|
+
geometry(image?.heatmap || image);
|
|
204
|
+
return decoder.encode(image.heatmap || image);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function pairCompatibility(build, basis, { reference = false } = {}) {
|
|
208
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
209
|
+
if (!build || typeof build !== "object" || !basis || typeof basis !== "object") return fail("capture_unavailable");
|
|
210
|
+
if (reference) {
|
|
211
|
+
if (!build || basis.viewport !== build.viewport) return fail("viewport_not_captured");
|
|
212
|
+
if (basis.css_width == null) return fail(basis.dpr == null ? "dpr_unknown" : "width_unknown");
|
|
213
|
+
if (basis.dpr == null) return fail("dpr_unknown");
|
|
214
|
+
if (basis.css_width !== build.width) return fail("width_mismatch");
|
|
215
|
+
if (basis.dpr !== build.device_scale_factor) return fail("dpr_mismatch");
|
|
216
|
+
} else if ([build, basis].some((cell) => cell?.stabilized?.treatment?.mask_cap_reached)) return fail("mask_cap_reached");
|
|
217
|
+
return { ok: true, reason: null };
|
|
218
|
+
}
|
|
219
|
+
export function pageCompatibility(a, b) {
|
|
220
|
+
return a?.truncated || b?.truncated ? { ok: false, reason: "capture_truncated" } : { ok: true, reason: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function pixelBand(source, band, dpr) {
|
|
224
|
+
const top = Math.floor(band.top * dpr), bottom = Math.ceil((band.top + band.height) * dpr);
|
|
225
|
+
const left = Math.floor((band.left ?? 0) * dpr);
|
|
226
|
+
const right = Math.ceil(((band.left ?? 0) + (band.width ?? source.width / dpr)) * dpr);
|
|
227
|
+
return { top, left, width: right - left, height: bottom - top };
|
|
228
|
+
}
|
|
229
|
+
function insideCapture(source, band, dpr) {
|
|
230
|
+
const left = band.left ?? 0, width = band.width ?? source.width / dpr;
|
|
231
|
+
return band.top >= 0 && left >= 0
|
|
232
|
+
&& (band.top + band.height) * dpr <= source.height && (left + width) * dpr <= source.width;
|
|
233
|
+
}
|
|
234
|
+
// A rectangle not wholly inside the decoded image is refused before any copy,
|
|
235
|
+
// so no zero-filled pixel is ever presented as image data.
|
|
236
|
+
export function crop(source, { top, left, width, height }, memory) {
|
|
237
|
+
boundedArea(width, height);
|
|
238
|
+
const image = readRgba(source, memory);
|
|
239
|
+
if (![top, left].every(Number.isSafeInteger) || top < 0 || left < 0
|
|
240
|
+
|| left + width > image.width || top + height > image.height) {
|
|
241
|
+
release(image, memory);
|
|
242
|
+
throw new RangeError("invalid_image_geometry");
|
|
243
|
+
}
|
|
244
|
+
const data = allocate(width * height * 4, memory);
|
|
245
|
+
for (let row = 0; row < height; row++) data.set(image.data.subarray(((row + top) * image.width + left) * 4,
|
|
246
|
+
((row + top) * image.width + left + width) * 4), row * width * 4);
|
|
247
|
+
release(image, memory);
|
|
248
|
+
return { width, height, data };
|
|
249
|
+
}
|
|
250
|
+
export function compareBands(basis, build, { basisSections, buildSections, rectsA = [], rectsB = [], dpr = 1, diff, cap = 40, memory, onComparison } = {}) {
|
|
251
|
+
boundedArea(own(basis, "width"), own(basis, "height")); boundedArea(own(build, "width"), own(build, "height"));
|
|
252
|
+
if (!Number.isFinite(dpr) || dpr <= 0) throw new RangeError("invalid_dpr");
|
|
253
|
+
if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError("invalid_band_cap");
|
|
254
|
+
cap = Math.min(cap, 40);
|
|
255
|
+
maskRects(rectsA); maskRects(rectsB);
|
|
256
|
+
const a = basisSections?.bands || [], b = buildSections?.bands || [];
|
|
257
|
+
if (!Array.isArray(a) || !Array.isArray(b) || [...a, ...b].some((band) => !band || typeof band !== "object")) throw new RangeError("invalid_band_geometry");
|
|
258
|
+
for (const [source, sections] of [[basis, a], [build, b]]) for (const band of sections) {
|
|
259
|
+
const width = band.width ?? source.width / dpr;
|
|
260
|
+
if (![band.top, band.height, band.left ?? 0, width].every(Number.isFinite)) throw new RangeError("invalid_band_geometry");
|
|
261
|
+
// Section boxes are signed, fractional CSS document coordinates. Only
|
|
262
|
+
// non-finite values and over-cap areas refuse the comparison (A-C1).
|
|
263
|
+
if (width > 0 && band.height > 0 && width * dpr * band.height * dpr > VISUAL_MAX_DECODE_PIXELS) throw new RangeError("image_too_large");
|
|
264
|
+
}
|
|
265
|
+
// Repeated bands read fresh images from caller-provided sources (normally
|
|
266
|
+
// capture files). No decoded originals or encoded snapshots are retained.
|
|
267
|
+
if ([basis, build].some((source) => typeof own(source, "load") !== "function" || source.data != null)) throw new RangeError("image_source_required");
|
|
268
|
+
const keys = [...new Set([...a, ...b].map((band) => band.key))].sort();
|
|
269
|
+
const bands = [], members = [];
|
|
270
|
+
const unsupported = (key, reason) => {
|
|
271
|
+
bands.push({ key, status: "unsupported", reason }); members.push({ key, result: "unexercised", reason_code: reason });
|
|
272
|
+
};
|
|
273
|
+
if (!keys.length) members.push({ key: "sections", result: "unexercised", reason_code: "no_section_keys" });
|
|
274
|
+
for (const [index, key] of keys.entries()) {
|
|
275
|
+
const aa = a.filter((band) => band.key === key), bb = b.filter((band) => band.key === key);
|
|
276
|
+
if (index >= cap) { unsupported(key, "band_cap_reached"); continue; }
|
|
277
|
+
if (aa.length > 1 || bb.length > 1 || [basisSections, buildSections].some((s) => s?.duplicated_keys?.includes(key))) { unsupported(key, "section_mapping_duplicate"); continue; }
|
|
278
|
+
if (!aa.length || !bb.length) { unsupported(key, "section_mapping_missing"); continue; }
|
|
279
|
+
const [sa] = aa, [sb] = bb;
|
|
280
|
+
if (sa.height <= 0 || sb.height <= 0 || sa.width <= 0 || sb.width <= 0) { unsupported(key, "section_not_rendered"); continue; }
|
|
281
|
+
if (!insideCapture(basis, sa, dpr) || !insideCapture(build, sb, dpr)) { unsupported(key, "beyond_capture_height"); continue; }
|
|
282
|
+
const pa = pixelBand(basis, sa, dpr), pb = pixelBand(build, sb, dpr);
|
|
283
|
+
if (pa.width <= 0 || pa.height <= 0 || pb.width <= 0 || pb.height <= 0) { unsupported(key, "section_not_rendered"); continue; }
|
|
284
|
+
const relativeRects = (rects, section) => rects.filter((r) => r.y < section.top + section.height && r.y + r.height > section.top)
|
|
285
|
+
.map((r) => ({ ...r, x: r.x - section.left, y: Math.max(0, r.y - section.top), height: Math.min(r.y + r.height, section.top + section.height) - Math.max(r.y, section.top) }));
|
|
286
|
+
const comparison = compareImages(crop(basis, pa, memory), crop(build, pb, memory), { diff, dpr, memory,
|
|
287
|
+
rectsA: relativeRects(rectsA, pa), rectsB: relativeRects(rectsB, pb) });
|
|
288
|
+
Object.assign(comparison, { key, basis: { top: sa.top, height: sa.height }, build: { top: sb.top, height: sb.height } });
|
|
289
|
+
try { onComparison?.(comparison); } finally { releaseComparison(comparison, memory); }
|
|
290
|
+
bands.push(comparison);
|
|
291
|
+
}
|
|
292
|
+
if (basisSections?.capped || buildSections?.capped) members.push({ key: "sections_cap", result: "unexercised", reason_code: "band_cap_reached" });
|
|
293
|
+
bands.sort((a, b) => (b.mismatch_ratio ?? -1) - (a.mismatch_ratio ?? -1)
|
|
294
|
+
|| Math.abs(b.height_delta_px || 0) - Math.abs(a.height_delta_px || 0) || a.key.localeCompare(b.key));
|
|
295
|
+
return { bands, members };
|
|
296
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync, realpathSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { readPngStructure, boundedInflate, sha256Hex, VISUAL_MAX_DECODE_PIXELS, VISUAL_MAX_REFERENCE_BYTES } from "./png-header.mjs";
|
|
4
|
+
|
|
5
|
+
const inside = (root, path) => {
|
|
6
|
+
if (!root || !path) return false;
|
|
7
|
+
const rel = relative(root, path);
|
|
8
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../"));
|
|
9
|
+
};
|
|
10
|
+
const real = (path) => { try { return realpathSync(path); } catch { return resolve(path); } };
|
|
11
|
+
const hash = (bytes) => `sha256:${sha256Hex(bytes)}`;
|
|
12
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : undefined;
|
|
14
|
+
|
|
15
|
+
export function referenceViewport({ ihdr, scale_factor, width, height } = {}) {
|
|
16
|
+
if (!Number.isFinite(scale_factor) || scale_factor <= 0) return { css_width: null, dpr: null, reason: "dpr_unknown" };
|
|
17
|
+
const w = ihdr?.width_px ?? width;
|
|
18
|
+
return { css_width: Number.isFinite(w) && w > 0 ? w / scale_factor : null,
|
|
19
|
+
dpr: scale_factor, reason: w > 0 ? null : "width_unknown" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// linkedSource is the independently read html_file identity + provenance.
|
|
23
|
+
export function classifyProvenance({ path, sourceRoot, targetRepo, kind = "source_screenshot", sha256,
|
|
24
|
+
buildCaptureHashes = [], builtHtmlHashes = [], pageKitTargets = [], linkedSource = null,
|
|
25
|
+
recorded = {}, source_ref_id = own(recorded, "source_ref_id"), captured_at = own(recorded, "captured_at") } = {}) {
|
|
26
|
+
recorded = object(recorded) ? recorded : {};
|
|
27
|
+
const reasons = [];
|
|
28
|
+
const html = kind === "html_file" || kind === "source_html";
|
|
29
|
+
const resolved = path ? real(path) : null;
|
|
30
|
+
const paths = html ? [resolved, sourceRoot ? real(sourceRoot) : null] : [resolved];
|
|
31
|
+
if (targetRepo) {
|
|
32
|
+
if (paths.some((p) => inside(real(resolve(targetRepo, "_site")), p))) reasons.push("path_in_build_output");
|
|
33
|
+
if (paths.some((p) => inside(real(resolve(targetRepo, ".campaign-runtime")), p))) reasons.push("path_in_runtime_dir");
|
|
34
|
+
}
|
|
35
|
+
if (html) {
|
|
36
|
+
if (sha256 && new Set(builtHtmlHashes).has(sha256)) reasons.push("hash_equals_built_html");
|
|
37
|
+
if (resolved && pageKitTargets.some((p) => real(resolve(targetRepo || ".", p)) === resolved)) reasons.push("source_is_campaign_page");
|
|
38
|
+
} else {
|
|
39
|
+
const hex = String(sha256 || "").replace(/^sha256:/, "");
|
|
40
|
+
if (sha256 && (new Set(buildCaptureHashes).has(sha256) || (targetRepo && /^[a-f0-9]{64}$/.test(hex)
|
|
41
|
+
&& ["captures", "readability", "diff"].some((dir) => existsSync(resolve(targetRepo, `.campaign-runtime/polish/${dir}/${hex}.png`)))))) reasons.push("hash_equals_package_capture");
|
|
42
|
+
if (linkedSource?.provenance?.class === "self_referential") reasons.push("linked_source_self_referential");
|
|
43
|
+
}
|
|
44
|
+
if (reasons.length) return { class: "self_referential", reasons };
|
|
45
|
+
if (!html && !captured_at) reasons.push("capture_time_unrecorded");
|
|
46
|
+
if (!html && (!source_ref_id || !linkedSource || !["html_file", "source_html"].includes(linkedSource.kind))) reasons.push("source_link_missing");
|
|
47
|
+
else {
|
|
48
|
+
const identity = html ? { sha256, sha256_recorded: own(recorded, "sha256") } : own(linkedSource, "identity");
|
|
49
|
+
// The source HTML applies the hash rules to its own html_file ref (§5.2):
|
|
50
|
+
// a ref that records no sha256 is unrecorded whether or not the file was
|
|
51
|
+
// read; a recorded ref whose file was not read is missing.
|
|
52
|
+
if (html && !identity.sha256_recorded) reasons.push("source_hash_unrecorded");
|
|
53
|
+
else if (!identity?.sha256) reasons.push("linked_source_missing");
|
|
54
|
+
else if (!identity.sha256_recorded) reasons.push("source_hash_unrecorded");
|
|
55
|
+
else if (identity.sha256_recorded !== identity.sha256) reasons.push("source_hash_mismatch");
|
|
56
|
+
}
|
|
57
|
+
return { class: reasons.length ? "unknown" : "declared_source", reasons };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readBounded(path, maxBytes) {
|
|
61
|
+
const fd = openSync(path, "r");
|
|
62
|
+
try {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let length = 0;
|
|
65
|
+
while (length <= maxBytes) {
|
|
66
|
+
const chunk = Buffer.alloc(Math.min(65536, maxBytes + 1 - length));
|
|
67
|
+
const n = readSync(fd, chunk, 0, chunk.length, null);
|
|
68
|
+
if (!n) break;
|
|
69
|
+
chunks.push(chunk.subarray(0, n)); length += n;
|
|
70
|
+
}
|
|
71
|
+
if (length > maxBytes) return null;
|
|
72
|
+
return Buffer.concat(chunks, length);
|
|
73
|
+
} finally { closeSync(fd); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function verifyReference({ path, url, recorded = {}, sourceRoot, targetRepo, decoder,
|
|
77
|
+
budget = null, kind = "source_screenshot", ...provenanceInputs } = {}) {
|
|
78
|
+
recorded = object(recorded) ? Object.fromEntries(Object.entries(recorded)) : {};
|
|
79
|
+
let resolved = null;
|
|
80
|
+
const identity = { sha256: null, sha256_recorded: recorded.sha256 ?? null, bytes: null,
|
|
81
|
+
...Object.fromEntries(["captured_at", "browser", "device_profile", "scale_factor", "width", "height", "source_ref_id"].map((key) => [key, own(recorded, key) ?? null])) };
|
|
82
|
+
const result = { kind, path_rel: path ? relative(targetRepo || sourceRoot || ".", resolve(sourceRoot || ".", path)) : null, url_present: Boolean(url), identity,
|
|
83
|
+
verification: { level: "unverifiable", reason: null, png: null }, css_width: null, dpr: null };
|
|
84
|
+
const finish = (reason, level = "unverifiable") => {
|
|
85
|
+
result.verification.reason = reason; result.verification.level = level;
|
|
86
|
+
result.provenance = classifyProvenance({ ...provenanceInputs, path: resolved, sourceRoot, targetRepo, kind,
|
|
87
|
+
sha256: identity.sha256, recorded });
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
if (!path) return finish(url ? "remote_not_fetched" : "file_missing");
|
|
91
|
+
try { resolved = realpathSync(resolve(sourceRoot || ".", path)); } catch { return finish("file_missing"); }
|
|
92
|
+
result.path_rel = relative(targetRepo || sourceRoot || ".", resolved);
|
|
93
|
+
if (!inside(real(sourceRoot || "."), resolved)
|
|
94
|
+
&& !(targetRepo && ["_site", ".campaign-runtime"].some((dir) => inside(real(resolve(targetRepo, dir)), resolved)))) return finish("path_outside_source_root");
|
|
95
|
+
let bytes;
|
|
96
|
+
try { bytes = readBounded(resolved, VISUAL_MAX_REFERENCE_BYTES); } catch { return finish("file_missing"); }
|
|
97
|
+
if (!bytes) return finish("too_large");
|
|
98
|
+
identity.bytes = bytes.length; identity.sha256 = hash(bytes);
|
|
99
|
+
if (["html_file", "source_html"].includes(kind)) {
|
|
100
|
+
// HTML needs only a successful read/hash for verification. Its declared
|
|
101
|
+
// source hash is a separate provenance member (§5.2), not a decode claim.
|
|
102
|
+
return finish(null, "decoded");
|
|
103
|
+
}
|
|
104
|
+
const structure = readPngStructure(bytes);
|
|
105
|
+
if (!structure.ok) return finish(structure.reason);
|
|
106
|
+
const ihdr = structure.ihdr;
|
|
107
|
+
result.verification.png = ihdr;
|
|
108
|
+
Object.assign(result, referenceViewport({ ihdr, ...recorded }));
|
|
109
|
+
delete result.reason;
|
|
110
|
+
if (ihdr.width_px * ihdr.height_px > VISUAL_MAX_DECODE_PIXELS) return finish("image_too_large");
|
|
111
|
+
const decode = typeof decoder === "function" ? decoder : own(decoder, "decode");
|
|
112
|
+
const available = typeof decode === "function" && (!decoder.status || decoder.status === "available");
|
|
113
|
+
if (available) {
|
|
114
|
+
if (budget && ((typeof budget.remaining === "function" && budget.remaining() <= 0)
|
|
115
|
+
|| (Number.isFinite(budget.deadline) && (budget.clock || Date.now)() >= budget.deadline))) return finish("verify_budget_exhausted");
|
|
116
|
+
try {
|
|
117
|
+
boundedInflate(bytes, ihdr);
|
|
118
|
+
const image = await decode(bytes);
|
|
119
|
+
if (image.width !== ihdr.width_px || image.height !== ihdr.height_px) throw new Error("dimensions");
|
|
120
|
+
} catch { return finish("decode_failed"); }
|
|
121
|
+
}
|
|
122
|
+
if (recorded.sha256 && recorded.sha256 !== identity.sha256) return finish("recorded_hash_mismatch");
|
|
123
|
+
if ((recorded.width != null && recorded.width !== ihdr.width_px)
|
|
124
|
+
|| (recorded.height != null && recorded.height !== ihdr.height_px)) return finish("recorded_dimensions_mismatch");
|
|
125
|
+
return finish(null, available ? "decoded" : "header_checked");
|
|
126
|
+
}
|