lootforge 0.3.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 +87 -0
- package/README.md +764 -0
- package/bin/lootforge.js +28 -0
- package/dist/benchmarks/coarseToFineCost.d.ts +21 -0
- package/dist/benchmarks/coarseToFineCost.js +49 -0
- package/dist/benchmarks/coarseToFineCost.js.map +1 -0
- package/dist/checks/boundaryMetrics.d.ts +12 -0
- package/dist/checks/boundaryMetrics.js +102 -0
- package/dist/checks/boundaryMetrics.js.map +1 -0
- package/dist/checks/candidateScore.d.ts +11 -0
- package/dist/checks/candidateScore.js +462 -0
- package/dist/checks/candidateScore.js.map +1 -0
- package/dist/checks/commandParser.d.ts +5 -0
- package/dist/checks/commandParser.js +99 -0
- package/dist/checks/commandParser.js.map +1 -0
- package/dist/checks/consistencyOutliers.d.ts +42 -0
- package/dist/checks/consistencyOutliers.js +156 -0
- package/dist/checks/consistencyOutliers.js.map +1 -0
- package/dist/checks/imageAcceptance.d.ts +67 -0
- package/dist/checks/imageAcceptance.js +967 -0
- package/dist/checks/imageAcceptance.js.map +1 -0
- package/dist/checks/packInvariants.d.ts +56 -0
- package/dist/checks/packInvariants.js +1064 -0
- package/dist/checks/packInvariants.js.map +1 -0
- package/dist/checks/softAdapters.d.ts +25 -0
- package/dist/checks/softAdapters.js +275 -0
- package/dist/checks/softAdapters.js.map +1 -0
- package/dist/checks/vlmGate.d.ts +8 -0
- package/dist/checks/vlmGate.js +200 -0
- package/dist/checks/vlmGate.js.map +1 -0
- package/dist/cli/commands/atlas.d.ts +5 -0
- package/dist/cli/commands/atlas.js +18 -0
- package/dist/cli/commands/atlas.js.map +1 -0
- package/dist/cli/commands/eval.d.ts +6 -0
- package/dist/cli/commands/eval.js +23 -0
- package/dist/cli/commands/eval.js.map +1 -0
- package/dist/cli/commands/generate.d.ts +18 -0
- package/dist/cli/commands/generate.js +66 -0
- package/dist/cli/commands/generate.js.map +1 -0
- package/dist/cli/commands/init.d.ts +15 -0
- package/dist/cli/commands/init.js +146 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/package.d.ts +6 -0
- package/dist/cli/commands/package.js +27 -0
- package/dist/cli/commands/package.js.map +1 -0
- package/dist/cli/commands/plan.d.ts +16 -0
- package/dist/cli/commands/plan.js +49 -0
- package/dist/cli/commands/plan.js.map +1 -0
- package/dist/cli/commands/process.d.ts +14 -0
- package/dist/cli/commands/process.js +29 -0
- package/dist/cli/commands/process.js.map +1 -0
- package/dist/cli/commands/regenerate.d.ts +29 -0
- package/dist/cli/commands/regenerate.js +244 -0
- package/dist/cli/commands/regenerate.js.map +1 -0
- package/dist/cli/commands/review.d.ts +5 -0
- package/dist/cli/commands/review.js +18 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/commands/select.d.ts +6 -0
- package/dist/cli/commands/select.js +21 -0
- package/dist/cli/commands/select.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +16 -0
- package/dist/cli/commands/serve.js +100 -0
- package/dist/cli/commands/serve.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +17 -0
- package/dist/cli/commands/validate.js +108 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +157 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/parseArgs.d.ts +3 -0
- package/dist/cli/parseArgs.js +37 -0
- package/dist/cli/parseArgs.js.map +1 -0
- package/dist/contracts/stageArtifacts.d.ts +4031 -0
- package/dist/contracts/stageArtifacts.js +663 -0
- package/dist/contracts/stageArtifacts.js.map +1 -0
- package/dist/manifest/load.d.ts +3 -0
- package/dist/manifest/load.js +50 -0
- package/dist/manifest/load.js.map +1 -0
- package/dist/manifest/normalize-palette.d.ts +17 -0
- package/dist/manifest/normalize-palette.js +235 -0
- package/dist/manifest/normalize-palette.js.map +1 -0
- package/dist/manifest/normalize-policy.d.ts +48 -0
- package/dist/manifest/normalize-policy.js +239 -0
- package/dist/manifest/normalize-policy.js.map +1 -0
- package/dist/manifest/normalize-prompt.d.ts +14 -0
- package/dist/manifest/normalize-prompt.js +73 -0
- package/dist/manifest/normalize-prompt.js.map +1 -0
- package/dist/manifest/normalize-target.d.ts +49 -0
- package/dist/manifest/normalize-target.js +542 -0
- package/dist/manifest/normalize-target.js.map +1 -0
- package/dist/manifest/schema.d.ts +7570 -0
- package/dist/manifest/schema.js +373 -0
- package/dist/manifest/schema.js.map +1 -0
- package/dist/manifest/semantic-validation.d.ts +4 -0
- package/dist/manifest/semantic-validation.js +526 -0
- package/dist/manifest/semantic-validation.js.map +1 -0
- package/dist/manifest/types.d.ts +263 -0
- package/dist/manifest/types.js +2 -0
- package/dist/manifest/types.js.map +1 -0
- package/dist/manifest/validate.d.ts +12 -0
- package/dist/manifest/validate.js +221 -0
- package/dist/manifest/validate.js.map +1 -0
- package/dist/output/assetPackManifest.d.ts +19 -0
- package/dist/output/assetPackManifest.js +20 -0
- package/dist/output/assetPackManifest.js.map +1 -0
- package/dist/output/catalog.d.ts +60 -0
- package/dist/output/catalog.js +107 -0
- package/dist/output/catalog.js.map +1 -0
- package/dist/output/contactSheet.d.ts +13 -0
- package/dist/output/contactSheet.js +124 -0
- package/dist/output/contactSheet.js.map +1 -0
- package/dist/output/phaserManifest.d.ts +8 -0
- package/dist/output/phaserManifest.js +25 -0
- package/dist/output/phaserManifest.js.map +1 -0
- package/dist/output/pixiManifest.d.ts +8 -0
- package/dist/output/pixiManifest.js +37 -0
- package/dist/output/pixiManifest.js.map +1 -0
- package/dist/output/provenance.d.ts +121 -0
- package/dist/output/provenance.js +10 -0
- package/dist/output/provenance.js.map +1 -0
- package/dist/output/runtimeManifests.d.ts +21 -0
- package/dist/output/runtimeManifests.js +82 -0
- package/dist/output/runtimeManifests.js.map +1 -0
- package/dist/output/unityImportManifest.d.ts +10 -0
- package/dist/output/unityImportManifest.js +58 -0
- package/dist/output/unityImportManifest.js.map +1 -0
- package/dist/output/zip.d.ts +5 -0
- package/dist/output/zip.js +68 -0
- package/dist/output/zip.js.map +1 -0
- package/dist/pipeline/atlas.d.ts +33 -0
- package/dist/pipeline/atlas.js +286 -0
- package/dist/pipeline/atlas.js.map +1 -0
- package/dist/pipeline/eval.d.ts +104 -0
- package/dist/pipeline/eval.js +246 -0
- package/dist/pipeline/eval.js.map +1 -0
- package/dist/pipeline/generate.d.ts +44 -0
- package/dist/pipeline/generate.js +1088 -0
- package/dist/pipeline/generate.js.map +1 -0
- package/dist/pipeline/package.d.ts +18 -0
- package/dist/pipeline/package.js +218 -0
- package/dist/pipeline/package.js.map +1 -0
- package/dist/pipeline/process.d.ts +15 -0
- package/dist/pipeline/process.js +776 -0
- package/dist/pipeline/process.js.map +1 -0
- package/dist/pipeline/review.d.ts +10 -0
- package/dist/pipeline/review.js +341 -0
- package/dist/pipeline/review.js.map +1 -0
- package/dist/pipeline/seamHeal.d.ts +2 -0
- package/dist/pipeline/seamHeal.js +70 -0
- package/dist/pipeline/seamHeal.js.map +1 -0
- package/dist/pipeline/select.d.ts +39 -0
- package/dist/pipeline/select.js +79 -0
- package/dist/pipeline/select.js.map +1 -0
- package/dist/providers/job.d.ts +29 -0
- package/dist/providers/job.js +113 -0
- package/dist/providers/job.js.map +1 -0
- package/dist/providers/localDiffusion.d.ts +28 -0
- package/dist/providers/localDiffusion.js +235 -0
- package/dist/providers/localDiffusion.js.map +1 -0
- package/dist/providers/nano.d.ts +36 -0
- package/dist/providers/nano.js +402 -0
- package/dist/providers/nano.js.map +1 -0
- package/dist/providers/openai.d.ts +37 -0
- package/dist/providers/openai.js +378 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/policy.d.ts +9 -0
- package/dist/providers/policy.js +192 -0
- package/dist/providers/policy.js.map +1 -0
- package/dist/providers/prompt.d.ts +3 -0
- package/dist/providers/prompt.js +63 -0
- package/dist/providers/prompt.js.map +1 -0
- package/dist/providers/registry.d.ts +24 -0
- package/dist/providers/registry.js +92 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/runtime.d.ts +15 -0
- package/dist/providers/runtime.js +101 -0
- package/dist/providers/runtime.js.map +1 -0
- package/dist/providers/runtimeConfig.d.ts +20 -0
- package/dist/providers/runtimeConfig.js +146 -0
- package/dist/providers/runtimeConfig.js.map +1 -0
- package/dist/providers/types-core.d.ts +514 -0
- package/dist/providers/types-core.js +60 -0
- package/dist/providers/types-core.js.map +1 -0
- package/dist/providers/types.d.ts +4 -0
- package/dist/providers/types.js +5 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/service/generationRequest.d.ts +58 -0
- package/dist/service/generationRequest.js +203 -0
- package/dist/service/generationRequest.js.map +1 -0
- package/dist/service/providerCapabilities.d.ts +40 -0
- package/dist/service/providerCapabilities.js +114 -0
- package/dist/service/providerCapabilities.js.map +1 -0
- package/dist/service/server.d.ts +31 -0
- package/dist/service/server.js +774 -0
- package/dist/service/server.js.map +1 -0
- package/dist/shared/errors.d.ts +13 -0
- package/dist/shared/errors.js +24 -0
- package/dist/shared/errors.js.map +1 -0
- package/dist/shared/fs.d.ts +6 -0
- package/dist/shared/fs.js +30 -0
- package/dist/shared/fs.js.map +1 -0
- package/dist/shared/image.d.ts +25 -0
- package/dist/shared/image.js +136 -0
- package/dist/shared/image.js.map +1 -0
- package/dist/shared/paths.d.ts +30 -0
- package/dist/shared/paths.js +103 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/schemas.d.ts +209 -0
- package/dist/shared/schemas.js +93 -0
- package/dist/shared/schemas.js.map +1 -0
- package/dist/shared/typeGuards.d.ts +1 -0
- package/dist/shared/typeGuards.js +4 -0
- package/dist/shared/typeGuards.js.map +1 -0
- package/dist/shared/zod.d.ts +1 -0
- package/dist/shared/zod.js +14 -0
- package/dist/shared/zod.js.map +1 -0
- package/dist/showcase/format.d.ts +9 -0
- package/dist/showcase/format.js +61 -0
- package/dist/showcase/format.js.map +1 -0
- package/dist/showcase/panelRenderer.d.ts +59 -0
- package/dist/showcase/panelRenderer.js +294 -0
- package/dist/showcase/panelRenderer.js.map +1 -0
- package/dist/showcase/releaseConfig.d.ts +233 -0
- package/dist/showcase/releaseConfig.js +75 -0
- package/dist/showcase/releaseConfig.js.map +1 -0
- package/dist/showcase/releaseEvidence.d.ts +25 -0
- package/dist/showcase/releaseEvidence.js +540 -0
- package/dist/showcase/releaseEvidence.js.map +1 -0
- package/dist/showcase/releaseEvidenceSchema.d.ts +1611 -0
- package/dist/showcase/releaseEvidenceSchema.js +165 -0
- package/dist/showcase/releaseEvidenceSchema.js.map +1 -0
- package/dist/showcase/scenarioRenderer.d.ts +19 -0
- package/dist/showcase/scenarioRenderer.js +488 -0
- package/dist/showcase/scenarioRenderer.js.map +1 -0
- package/docs/ADAPTER_CONTRACT.md +141 -0
- package/docs/ENGINE_TARGETING.md +86 -0
- package/docs/MANIFEST_POLICY_COVERAGE.md +130 -0
- package/docs/RELEASE_WORKFLOW.md +117 -0
- package/docs/ROADMAP.md +411 -0
- package/docs/ROADMAP_ISSUES.md +244 -0
- package/docs/SERVICE_MODE.md +137 -0
- package/docs/manifest-schema.md +254 -0
- package/package.json +70 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { computeBoundaryQualityMetrics } from "./boundaryMetrics.js";
|
|
4
|
+
import { runPackInvariantChecks } from "./packInvariants.js";
|
|
5
|
+
import { normalizeOutputFormatAlias } from "../providers/types.js";
|
|
6
|
+
import { openImage, parseSize } from "../shared/image.js";
|
|
7
|
+
import { normalizeTargetOutPath, resolvePathWithinDir } from "../shared/paths.js";
|
|
8
|
+
const DEFAULT_PALETTE_COMPLIANCE_MIN = 0.98;
|
|
9
|
+
async function inspectImage(imagePath) {
|
|
10
|
+
const [metadata, rawResult] = await Promise.all([
|
|
11
|
+
openImage(imagePath, "qa").metadata(),
|
|
12
|
+
openImage(imagePath, "qa").ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
|
|
13
|
+
]);
|
|
14
|
+
if (typeof metadata.width !== "number" ||
|
|
15
|
+
typeof metadata.height !== "number" ||
|
|
16
|
+
metadata.width <= 0 ||
|
|
17
|
+
metadata.height <= 0) {
|
|
18
|
+
throw new Error(`Unable to decode dimensions for ${imagePath}`);
|
|
19
|
+
}
|
|
20
|
+
const format = metadata.format ?? "unknown";
|
|
21
|
+
const hasAlphaChannel = metadata.hasAlpha;
|
|
22
|
+
const channels = rawResult.info.channels;
|
|
23
|
+
const raw = rawResult.data;
|
|
24
|
+
const hasTransparentPixels = channels >= 4 ? hasAnyTransparentPixels(raw, channels) : false;
|
|
25
|
+
const sizeBytes = typeof metadata.size === "number" && metadata.size > 0
|
|
26
|
+
? metadata.size
|
|
27
|
+
: (await stat(imagePath)).size;
|
|
28
|
+
return {
|
|
29
|
+
width: metadata.width,
|
|
30
|
+
height: metadata.height,
|
|
31
|
+
format,
|
|
32
|
+
sizeBytes,
|
|
33
|
+
hasAlphaChannel,
|
|
34
|
+
hasTransparentPixels,
|
|
35
|
+
raw,
|
|
36
|
+
channels,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function hasAnyTransparentPixels(raw, channels) {
|
|
40
|
+
if (channels < 4) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
for (let index = 3; index < raw.length; index += channels) {
|
|
44
|
+
if (raw[index] < 255) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function targetRequiresAlpha(target) {
|
|
51
|
+
if (target.runtimeSpec?.alphaRequired === true) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return target.acceptance?.alpha === true;
|
|
55
|
+
}
|
|
56
|
+
function outputFormatSupportsAlpha(format) {
|
|
57
|
+
return format === "png" || format === "webp";
|
|
58
|
+
}
|
|
59
|
+
function computeSeamScore(inspected, stripPx) {
|
|
60
|
+
return computeSeamScoreForRegion(inspected, 0, 0, inspected.width, inspected.height, stripPx);
|
|
61
|
+
}
|
|
62
|
+
function computeSeamScoreForRegion(inspected, startX, startY, regionWidth, regionHeight, stripPx) {
|
|
63
|
+
const channels = inspected.channels;
|
|
64
|
+
const strip = Math.max(1, Math.min(stripPx, Math.floor(Math.min(regionWidth, regionHeight) / 2)));
|
|
65
|
+
const mad = (a, b) => Math.abs(a - b);
|
|
66
|
+
let total = 0;
|
|
67
|
+
let count = 0;
|
|
68
|
+
// Left vs right strips.
|
|
69
|
+
for (let y = 0; y < regionHeight; y += 1) {
|
|
70
|
+
for (let x = 0; x < strip; x += 1) {
|
|
71
|
+
const leftIndex = ((startY + y) * inspected.width + (startX + x)) * channels;
|
|
72
|
+
const rightIndex = ((startY + y) * inspected.width + (startX + regionWidth - strip + x)) * channels;
|
|
73
|
+
total += mad(inspected.raw[leftIndex], inspected.raw[rightIndex]);
|
|
74
|
+
total += mad(inspected.raw[leftIndex + 1], inspected.raw[rightIndex + 1]);
|
|
75
|
+
total += mad(inspected.raw[leftIndex + 2], inspected.raw[rightIndex + 2]);
|
|
76
|
+
count += 3;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Top vs bottom strips.
|
|
80
|
+
for (let y = 0; y < strip; y += 1) {
|
|
81
|
+
for (let x = 0; x < regionWidth; x += 1) {
|
|
82
|
+
const topIndex = ((startY + y) * inspected.width + (startX + x)) * channels;
|
|
83
|
+
const bottomIndex = ((startY + regionHeight - strip + y) * inspected.width + (startX + x)) * channels;
|
|
84
|
+
total += mad(inspected.raw[topIndex], inspected.raw[bottomIndex]);
|
|
85
|
+
total += mad(inspected.raw[topIndex + 1], inspected.raw[bottomIndex + 1]);
|
|
86
|
+
total += mad(inspected.raw[topIndex + 2], inspected.raw[bottomIndex + 2]);
|
|
87
|
+
count += 3;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (count === 0) {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
return total / count;
|
|
94
|
+
}
|
|
95
|
+
function computeWrapGridSeamScore(inspected, columns, rows, stripPx) {
|
|
96
|
+
if (columns <= 0 ||
|
|
97
|
+
rows <= 0 ||
|
|
98
|
+
inspected.width % columns !== 0 ||
|
|
99
|
+
inspected.height % rows !== 0) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const cellWidth = inspected.width / columns;
|
|
103
|
+
const cellHeight = inspected.height / rows;
|
|
104
|
+
let total = 0;
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (let row = 0; row < rows; row += 1) {
|
|
107
|
+
for (let column = 0; column < columns; column += 1) {
|
|
108
|
+
total += computeSeamScoreForRegion(inspected, column * cellWidth, row * cellHeight, cellWidth, cellHeight, stripPx);
|
|
109
|
+
count += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (count === 0) {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
return total / count;
|
|
116
|
+
}
|
|
117
|
+
function evaluateWrapGridTopology(params) {
|
|
118
|
+
const { inspected, columns, rows } = params;
|
|
119
|
+
if (columns <= 0 ||
|
|
120
|
+
rows <= 0 ||
|
|
121
|
+
inspected.width % columns !== 0 ||
|
|
122
|
+
inspected.height % rows !== 0) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const cellWidth = inspected.width / columns;
|
|
126
|
+
const cellHeight = inspected.height / rows;
|
|
127
|
+
const colorTolerance = Math.max(0, Math.min(255, Math.round(params.colorTolerance)));
|
|
128
|
+
if (params.mode === "self") {
|
|
129
|
+
let comparisons = 0;
|
|
130
|
+
let mismatchTotal = 0;
|
|
131
|
+
for (let row = 0; row < rows; row += 1) {
|
|
132
|
+
for (let column = 0; column < columns; column += 1) {
|
|
133
|
+
const startX = column * cellWidth;
|
|
134
|
+
const startY = row * cellHeight;
|
|
135
|
+
mismatchTotal += compareEdgeMismatchRatio({
|
|
136
|
+
inspected,
|
|
137
|
+
startX,
|
|
138
|
+
startY,
|
|
139
|
+
cellWidth,
|
|
140
|
+
cellHeight,
|
|
141
|
+
leftEdge: "left",
|
|
142
|
+
rightEdge: "right",
|
|
143
|
+
colorTolerance,
|
|
144
|
+
});
|
|
145
|
+
mismatchTotal += compareEdgeMismatchRatio({
|
|
146
|
+
inspected,
|
|
147
|
+
startX,
|
|
148
|
+
startY,
|
|
149
|
+
cellWidth,
|
|
150
|
+
cellHeight,
|
|
151
|
+
leftEdge: "top",
|
|
152
|
+
rightEdge: "bottom",
|
|
153
|
+
colorTolerance,
|
|
154
|
+
});
|
|
155
|
+
comparisons += 2;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
mode: params.mode,
|
|
160
|
+
comparisons,
|
|
161
|
+
mismatchRatio: comparisons > 0 ? mismatchTotal / comparisons : 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (params.mode === "one-to-one") {
|
|
165
|
+
let comparisons = 0;
|
|
166
|
+
let mismatchTotal = 0;
|
|
167
|
+
for (let row = 0; row < rows; row += 1) {
|
|
168
|
+
for (let column = 0; column < columns; column += 1) {
|
|
169
|
+
const startX = column * cellWidth;
|
|
170
|
+
const startY = row * cellHeight;
|
|
171
|
+
if (column + 1 < columns) {
|
|
172
|
+
mismatchTotal += compareEdgeMismatchRatio({
|
|
173
|
+
inspected,
|
|
174
|
+
startX,
|
|
175
|
+
startY,
|
|
176
|
+
cellWidth,
|
|
177
|
+
cellHeight,
|
|
178
|
+
leftEdge: "right",
|
|
179
|
+
rightEdge: "left",
|
|
180
|
+
rightStartX: (column + 1) * cellWidth,
|
|
181
|
+
rightStartY: startY,
|
|
182
|
+
colorTolerance,
|
|
183
|
+
});
|
|
184
|
+
comparisons += 1;
|
|
185
|
+
}
|
|
186
|
+
if (row + 1 < rows) {
|
|
187
|
+
mismatchTotal += compareEdgeMismatchRatio({
|
|
188
|
+
inspected,
|
|
189
|
+
startX,
|
|
190
|
+
startY,
|
|
191
|
+
cellWidth,
|
|
192
|
+
cellHeight,
|
|
193
|
+
leftEdge: "bottom",
|
|
194
|
+
rightEdge: "top",
|
|
195
|
+
rightStartX: startX,
|
|
196
|
+
rightStartY: (row + 1) * cellHeight,
|
|
197
|
+
colorTolerance,
|
|
198
|
+
});
|
|
199
|
+
comparisons += 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
mode: params.mode,
|
|
205
|
+
comparisons,
|
|
206
|
+
mismatchRatio: comparisons > 0 ? mismatchTotal / comparisons : 0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
let comparisons = 0;
|
|
210
|
+
let mismatched = 0;
|
|
211
|
+
const leftEdgeKeys = new Set();
|
|
212
|
+
const topEdgeKeys = new Set();
|
|
213
|
+
const rightEdges = [];
|
|
214
|
+
const bottomEdges = [];
|
|
215
|
+
for (let row = 0; row < rows; row += 1) {
|
|
216
|
+
for (let column = 0; column < columns; column += 1) {
|
|
217
|
+
const startX = column * cellWidth;
|
|
218
|
+
const startY = row * cellHeight;
|
|
219
|
+
leftEdgeKeys.add(createEdgeSignature({
|
|
220
|
+
inspected,
|
|
221
|
+
startX,
|
|
222
|
+
startY,
|
|
223
|
+
cellWidth,
|
|
224
|
+
cellHeight,
|
|
225
|
+
edge: "left",
|
|
226
|
+
colorTolerance,
|
|
227
|
+
}));
|
|
228
|
+
topEdgeKeys.add(createEdgeSignature({
|
|
229
|
+
inspected,
|
|
230
|
+
startX,
|
|
231
|
+
startY,
|
|
232
|
+
cellWidth,
|
|
233
|
+
cellHeight,
|
|
234
|
+
edge: "top",
|
|
235
|
+
colorTolerance,
|
|
236
|
+
}));
|
|
237
|
+
rightEdges.push(createEdgeSignature({
|
|
238
|
+
inspected,
|
|
239
|
+
startX,
|
|
240
|
+
startY,
|
|
241
|
+
cellWidth,
|
|
242
|
+
cellHeight,
|
|
243
|
+
edge: "right",
|
|
244
|
+
colorTolerance,
|
|
245
|
+
}));
|
|
246
|
+
bottomEdges.push(createEdgeSignature({
|
|
247
|
+
inspected,
|
|
248
|
+
startX,
|
|
249
|
+
startY,
|
|
250
|
+
cellWidth,
|
|
251
|
+
cellHeight,
|
|
252
|
+
edge: "bottom",
|
|
253
|
+
colorTolerance,
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const key of rightEdges) {
|
|
258
|
+
comparisons += 1;
|
|
259
|
+
if (!leftEdgeKeys.has(key)) {
|
|
260
|
+
mismatched += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const key of bottomEdges) {
|
|
264
|
+
comparisons += 1;
|
|
265
|
+
if (!topEdgeKeys.has(key)) {
|
|
266
|
+
mismatched += 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
mode: params.mode,
|
|
271
|
+
comparisons,
|
|
272
|
+
mismatchRatio: comparisons > 0 ? mismatched / comparisons : 0,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function resolveWrapGridTopologyIssueCode(mode) {
|
|
276
|
+
if (mode === "self") {
|
|
277
|
+
return "wrap_grid_topology_self_exceeded";
|
|
278
|
+
}
|
|
279
|
+
if (mode === "one-to-one") {
|
|
280
|
+
return "wrap_grid_topology_pair_exceeded";
|
|
281
|
+
}
|
|
282
|
+
return "wrap_grid_topology_compatibility_exceeded";
|
|
283
|
+
}
|
|
284
|
+
function computeVisualStyleMetrics(inspected) {
|
|
285
|
+
const bins = 32;
|
|
286
|
+
const histogram = new Array(bins).fill(0);
|
|
287
|
+
let visiblePixels = 0;
|
|
288
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
289
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
290
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
291
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
292
|
+
let contrastTotal = 0;
|
|
293
|
+
let contrastCount = 0;
|
|
294
|
+
const lumaAt = (index) => 0.2126 * inspected.raw[index] +
|
|
295
|
+
0.7152 * inspected.raw[index + 1] +
|
|
296
|
+
0.0722 * inspected.raw[index + 2];
|
|
297
|
+
const alphaAt = (index) => inspected.channels >= 4 ? inspected.raw[index + 3] : 255;
|
|
298
|
+
for (let y = 0; y < inspected.height; y += 1) {
|
|
299
|
+
for (let x = 0; x < inspected.width; x += 1) {
|
|
300
|
+
const index = (y * inspected.width + x) * inspected.channels;
|
|
301
|
+
if (alphaAt(index) === 0) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
visiblePixels += 1;
|
|
305
|
+
if (x < minX)
|
|
306
|
+
minX = x;
|
|
307
|
+
if (y < minY)
|
|
308
|
+
minY = y;
|
|
309
|
+
if (x > maxX)
|
|
310
|
+
maxX = x;
|
|
311
|
+
if (y > maxY)
|
|
312
|
+
maxY = y;
|
|
313
|
+
const luma = lumaAt(index);
|
|
314
|
+
const bin = Math.max(0, Math.min(bins - 1, Math.floor((luma / 256) * bins)));
|
|
315
|
+
histogram[bin] += 1;
|
|
316
|
+
if (x + 1 < inspected.width) {
|
|
317
|
+
const rightIndex = index + inspected.channels;
|
|
318
|
+
if (alphaAt(rightIndex) > 0) {
|
|
319
|
+
contrastTotal += Math.abs(luma - lumaAt(rightIndex));
|
|
320
|
+
contrastCount += 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (y + 1 < inspected.height) {
|
|
324
|
+
const bottomIndex = index + inspected.width * inspected.channels;
|
|
325
|
+
if (alphaAt(bottomIndex) > 0) {
|
|
326
|
+
contrastTotal += Math.abs(luma - lumaAt(bottomIndex));
|
|
327
|
+
contrastCount += 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const lineContrast = contrastCount > 0 ? contrastTotal / (contrastCount * 255) : 0;
|
|
333
|
+
const shadingBandCount = histogram.filter((count) => count > 0).length;
|
|
334
|
+
if (visiblePixels === 0) {
|
|
335
|
+
return {
|
|
336
|
+
lineContrast,
|
|
337
|
+
shadingBandCount,
|
|
338
|
+
uiRectilinearity: 0,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const bboxArea = Math.max(1, (maxX - minX + 1) * (maxY - minY + 1));
|
|
342
|
+
return {
|
|
343
|
+
lineContrast,
|
|
344
|
+
shadingBandCount,
|
|
345
|
+
uiRectilinearity: visiblePixels / bboxArea,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function computeMattingQualityMetrics(inspected) {
|
|
349
|
+
const totalPixels = Math.max(1, inspected.width * inspected.height);
|
|
350
|
+
let visiblePixels = 0;
|
|
351
|
+
let semiTransparentPixels = 0;
|
|
352
|
+
let hiddenLeakSum = 0;
|
|
353
|
+
let hiddenLeakCount = 0;
|
|
354
|
+
let semiOnOpaqueBoundary = 0;
|
|
355
|
+
const alphaAt = (x, y) => {
|
|
356
|
+
const index = (y * inspected.width + x) * inspected.channels;
|
|
357
|
+
return inspected.channels >= 4 ? inspected.raw[index + 3] : 255;
|
|
358
|
+
};
|
|
359
|
+
for (let y = 0; y < inspected.height; y += 1) {
|
|
360
|
+
for (let x = 0; x < inspected.width; x += 1) {
|
|
361
|
+
const index = (y * inspected.width + x) * inspected.channels;
|
|
362
|
+
const alpha = inspected.channels >= 4 ? inspected.raw[index + 3] : 255;
|
|
363
|
+
if (alpha === 0) {
|
|
364
|
+
hiddenLeakSum +=
|
|
365
|
+
Math.max(inspected.raw[index], inspected.raw[index + 1], inspected.raw[index + 2]) / 255;
|
|
366
|
+
hiddenLeakCount += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
visiblePixels += 1;
|
|
370
|
+
if (alpha < 255) {
|
|
371
|
+
semiTransparentPixels += 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (semiTransparentPixels > 0) {
|
|
376
|
+
for (let y = 0; y < inspected.height; y += 1) {
|
|
377
|
+
for (let x = 0; x < inspected.width; x += 1) {
|
|
378
|
+
const alpha = alphaAt(x, y);
|
|
379
|
+
if (alpha <= 0 || alpha >= 255) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
let touchesOpaque = false;
|
|
383
|
+
for (let dy = -1; dy <= 1 && !touchesOpaque; dy += 1) {
|
|
384
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
385
|
+
if (dx === 0 && dy === 0) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const nx = x + dx;
|
|
389
|
+
const ny = y + dy;
|
|
390
|
+
if (nx < 0 || ny < 0 || nx >= inspected.width || ny >= inspected.height) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (alphaAt(nx, ny) === 255) {
|
|
394
|
+
touchesOpaque = true;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (touchesOpaque) {
|
|
400
|
+
semiOnOpaqueBoundary += 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
maskCoverage: visiblePixels / totalPixels,
|
|
407
|
+
semiTransparencyRatio: visiblePixels > 0 ? semiTransparentPixels / Math.max(1, visiblePixels) : 0,
|
|
408
|
+
maskConsistency: semiTransparentPixels > 0 ? semiOnOpaqueBoundary / semiTransparentPixels : 1,
|
|
409
|
+
hiddenRgbLeak: hiddenLeakCount > 0 ? hiddenLeakSum / hiddenLeakCount : 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function compareEdgeMismatchRatio(params) {
|
|
413
|
+
const leftLength = edgeLength(params.leftEdge, params.cellWidth, params.cellHeight);
|
|
414
|
+
const rightLength = edgeLength(params.rightEdge, params.cellWidth, params.cellHeight);
|
|
415
|
+
if (leftLength !== rightLength || leftLength <= 0) {
|
|
416
|
+
return 1;
|
|
417
|
+
}
|
|
418
|
+
let mismatches = 0;
|
|
419
|
+
const rightStartX = params.rightStartX ?? params.startX;
|
|
420
|
+
const rightStartY = params.rightStartY ?? params.startY;
|
|
421
|
+
const colorTolerance = Math.max(0, Math.min(255, Math.round(params.colorTolerance)));
|
|
422
|
+
for (let offset = 0; offset < leftLength; offset += 1) {
|
|
423
|
+
const leftIndex = resolveEdgePixelIndex({
|
|
424
|
+
inspected: params.inspected,
|
|
425
|
+
startX: params.startX,
|
|
426
|
+
startY: params.startY,
|
|
427
|
+
cellWidth: params.cellWidth,
|
|
428
|
+
cellHeight: params.cellHeight,
|
|
429
|
+
edge: params.leftEdge,
|
|
430
|
+
offset,
|
|
431
|
+
});
|
|
432
|
+
const rightIndex = resolveEdgePixelIndex({
|
|
433
|
+
inspected: params.inspected,
|
|
434
|
+
startX: rightStartX,
|
|
435
|
+
startY: rightStartY,
|
|
436
|
+
cellWidth: params.cellWidth,
|
|
437
|
+
cellHeight: params.cellHeight,
|
|
438
|
+
edge: params.rightEdge,
|
|
439
|
+
offset,
|
|
440
|
+
});
|
|
441
|
+
let mismatched = false;
|
|
442
|
+
for (let channel = 0; channel < params.inspected.channels; channel += 1) {
|
|
443
|
+
if (Math.abs(params.inspected.raw[leftIndex + channel] - params.inspected.raw[rightIndex + channel]) > colorTolerance) {
|
|
444
|
+
mismatched = true;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (mismatched) {
|
|
449
|
+
mismatches += 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return mismatches / leftLength;
|
|
453
|
+
}
|
|
454
|
+
function createEdgeSignature(params) {
|
|
455
|
+
const length = edgeLength(params.edge, params.cellWidth, params.cellHeight);
|
|
456
|
+
const step = Math.max(1, Math.round(params.colorTolerance) + 1);
|
|
457
|
+
const segments = [];
|
|
458
|
+
for (let offset = 0; offset < length; offset += 1) {
|
|
459
|
+
const index = resolveEdgePixelIndex({
|
|
460
|
+
inspected: params.inspected,
|
|
461
|
+
startX: params.startX,
|
|
462
|
+
startY: params.startY,
|
|
463
|
+
cellWidth: params.cellWidth,
|
|
464
|
+
cellHeight: params.cellHeight,
|
|
465
|
+
edge: params.edge,
|
|
466
|
+
offset,
|
|
467
|
+
});
|
|
468
|
+
for (let channel = 0; channel < params.inspected.channels; channel += 1) {
|
|
469
|
+
const quantized = Math.floor(params.inspected.raw[index + channel] / step);
|
|
470
|
+
segments.push(quantized.toString(16).padStart(2, "0"));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return segments.join("");
|
|
474
|
+
}
|
|
475
|
+
function edgeLength(edge, cellWidth, cellHeight) {
|
|
476
|
+
if (edge === "left" || edge === "right") {
|
|
477
|
+
return cellHeight;
|
|
478
|
+
}
|
|
479
|
+
return cellWidth;
|
|
480
|
+
}
|
|
481
|
+
function resolveEdgePixelIndex(params) {
|
|
482
|
+
const x = params.edge === "left"
|
|
483
|
+
? params.startX
|
|
484
|
+
: params.edge === "right"
|
|
485
|
+
? params.startX + params.cellWidth - 1
|
|
486
|
+
: params.startX + params.offset;
|
|
487
|
+
const y = params.edge === "top"
|
|
488
|
+
? params.startY
|
|
489
|
+
: params.edge === "bottom"
|
|
490
|
+
? params.startY + params.cellHeight - 1
|
|
491
|
+
: params.startY + params.offset;
|
|
492
|
+
return (y * params.inspected.width + x) * params.inspected.channels;
|
|
493
|
+
}
|
|
494
|
+
function collectDistinctColors(inspected) {
|
|
495
|
+
const colors = new Set();
|
|
496
|
+
for (let i = 0; i < inspected.raw.length; i += inspected.channels) {
|
|
497
|
+
const alpha = inspected.channels >= 4 ? inspected.raw[i + 3] : 255;
|
|
498
|
+
if (alpha === 0) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const packed = (inspected.raw[i] << 16) | (inspected.raw[i + 1] << 8) | inspected.raw[i + 2];
|
|
502
|
+
colors.add(packed >>> 0);
|
|
503
|
+
}
|
|
504
|
+
return colors;
|
|
505
|
+
}
|
|
506
|
+
function hexToPackedColor(input) {
|
|
507
|
+
const normalized = input.trim().replace(/^#/, "");
|
|
508
|
+
const value = Number.parseInt(normalized, 16);
|
|
509
|
+
if (!Number.isFinite(value)) {
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
return value >>> 0;
|
|
513
|
+
}
|
|
514
|
+
function computeExactPaletteCompliance(inspected, allowedColors) {
|
|
515
|
+
if (allowedColors.size === 0) {
|
|
516
|
+
return { compliance: 0, distinctColors: 0 };
|
|
517
|
+
}
|
|
518
|
+
let matches = 0;
|
|
519
|
+
let counted = 0;
|
|
520
|
+
const distinctColors = new Set();
|
|
521
|
+
for (let i = 0; i < inspected.raw.length; i += inspected.channels) {
|
|
522
|
+
const alpha = inspected.channels >= 4 ? inspected.raw[i + 3] : 255;
|
|
523
|
+
if (alpha === 0) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const packed = (inspected.raw[i] << 16) | (inspected.raw[i + 1] << 8) | inspected.raw[i + 2];
|
|
527
|
+
distinctColors.add(packed >>> 0);
|
|
528
|
+
counted += 1;
|
|
529
|
+
if (allowedColors.has(packed >>> 0)) {
|
|
530
|
+
matches += 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (counted === 0) {
|
|
534
|
+
return { compliance: 1, distinctColors: 0 };
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
compliance: matches / counted,
|
|
538
|
+
distinctColors: distinctColors.size,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function requiredExactPaletteCompliance(target) {
|
|
542
|
+
if (target.palette?.mode === "exact" && target.palette.strict === true) {
|
|
543
|
+
return 1;
|
|
544
|
+
}
|
|
545
|
+
return DEFAULT_PALETTE_COMPLIANCE_MIN;
|
|
546
|
+
}
|
|
547
|
+
export async function evaluateImageAcceptance(target, imagesDir) {
|
|
548
|
+
const report = {
|
|
549
|
+
targetId: target.id,
|
|
550
|
+
out: target.out,
|
|
551
|
+
imagePath: "",
|
|
552
|
+
exists: false,
|
|
553
|
+
issues: [],
|
|
554
|
+
metrics: {},
|
|
555
|
+
};
|
|
556
|
+
let imagePath;
|
|
557
|
+
try {
|
|
558
|
+
const normalizedOut = normalizeTargetOutPath(target.out);
|
|
559
|
+
report.out = normalizedOut;
|
|
560
|
+
imagePath = resolvePathWithinDir(imagesDir, normalizedOut, `accepted image for target "${target.id}"`);
|
|
561
|
+
report.imagePath = imagePath;
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
report.issues.push({
|
|
565
|
+
level: "error",
|
|
566
|
+
code: "invalid_target_out_path",
|
|
567
|
+
targetId: target.id,
|
|
568
|
+
imagePath: path.join(imagesDir, target.out),
|
|
569
|
+
message: error instanceof Error ? error.message : String(error),
|
|
570
|
+
});
|
|
571
|
+
return report;
|
|
572
|
+
}
|
|
573
|
+
let inspected;
|
|
574
|
+
try {
|
|
575
|
+
inspected = await inspectImage(imagePath);
|
|
576
|
+
report.exists = true;
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
report.issues.push({
|
|
580
|
+
level: "error",
|
|
581
|
+
code: "missing_or_invalid_image",
|
|
582
|
+
targetId: target.id,
|
|
583
|
+
imagePath,
|
|
584
|
+
message: error instanceof Error
|
|
585
|
+
? error.message
|
|
586
|
+
: `Missing or unreadable image for target "${target.id}".`,
|
|
587
|
+
});
|
|
588
|
+
return report;
|
|
589
|
+
}
|
|
590
|
+
report.width = inspected.width;
|
|
591
|
+
report.height = inspected.height;
|
|
592
|
+
report.format = inspected.format;
|
|
593
|
+
report.sizeBytes = inspected.sizeBytes;
|
|
594
|
+
report.hasAlphaChannel = inspected.hasAlphaChannel;
|
|
595
|
+
report.hasTransparentPixels = inspected.hasTransparentPixels;
|
|
596
|
+
const expectedSize = parseSize(target.acceptance?.size);
|
|
597
|
+
if (expectedSize &&
|
|
598
|
+
(inspected.width !== expectedSize.width || inspected.height !== expectedSize.height)) {
|
|
599
|
+
report.issues.push({
|
|
600
|
+
level: "error",
|
|
601
|
+
code: "size_mismatch",
|
|
602
|
+
targetId: target.id,
|
|
603
|
+
imagePath,
|
|
604
|
+
message: `Expected ${expectedSize.width}x${expectedSize.height} but got ${inspected.width}x${inspected.height}.`,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const requestedOutputFormat = normalizeOutputFormatAlias(target.generationPolicy?.outputFormat);
|
|
608
|
+
const actualOutputFormat = normalizeOutputFormatAlias(inspected.format);
|
|
609
|
+
if (actualOutputFormat !== requestedOutputFormat) {
|
|
610
|
+
report.issues.push({
|
|
611
|
+
level: "error",
|
|
612
|
+
code: "output_format_mismatch",
|
|
613
|
+
targetId: target.id,
|
|
614
|
+
imagePath,
|
|
615
|
+
message: `Expected ${requestedOutputFormat} but got ${actualOutputFormat}.`,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
if (targetRequiresAlpha(target)) {
|
|
619
|
+
if (!inspected.hasAlphaChannel || !outputFormatSupportsAlpha(actualOutputFormat)) {
|
|
620
|
+
report.issues.push({
|
|
621
|
+
level: "error",
|
|
622
|
+
code: "alpha_channel_missing",
|
|
623
|
+
targetId: target.id,
|
|
624
|
+
imagePath,
|
|
625
|
+
message: "Target requires alpha but output format/channel is not alpha-capable.",
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (!inspected.hasTransparentPixels) {
|
|
629
|
+
report.issues.push({
|
|
630
|
+
level: "error",
|
|
631
|
+
code: "alpha_pixels_missing",
|
|
632
|
+
targetId: target.id,
|
|
633
|
+
imagePath,
|
|
634
|
+
message: "Target requires transparency but all pixels are fully opaque.",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const maxFileSizeKB = target.acceptance?.maxFileSizeKB;
|
|
639
|
+
if (typeof maxFileSizeKB === "number") {
|
|
640
|
+
const maxBytes = Math.round(maxFileSizeKB * 1024);
|
|
641
|
+
if (inspected.sizeBytes > maxBytes) {
|
|
642
|
+
report.issues.push({
|
|
643
|
+
level: "error",
|
|
644
|
+
code: "file_size_exceeded",
|
|
645
|
+
targetId: target.id,
|
|
646
|
+
imagePath,
|
|
647
|
+
message: `File size ${(inspected.sizeBytes / 1024).toFixed(1)}KB exceeds max ${maxFileSizeKB}KB.`,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const boundaryMetrics = inspected.hasAlphaChannel && inspected.hasTransparentPixels
|
|
652
|
+
? computeBoundaryQualityMetrics({
|
|
653
|
+
raw: inspected.raw,
|
|
654
|
+
channels: inspected.channels,
|
|
655
|
+
width: inspected.width,
|
|
656
|
+
height: inspected.height,
|
|
657
|
+
})
|
|
658
|
+
: undefined;
|
|
659
|
+
if (boundaryMetrics) {
|
|
660
|
+
report.metrics = {
|
|
661
|
+
...report.metrics,
|
|
662
|
+
alphaBoundaryPixels: boundaryMetrics.edgePixelCount,
|
|
663
|
+
alphaHaloRisk: boundaryMetrics.haloRisk,
|
|
664
|
+
alphaStrayNoise: boundaryMetrics.strayNoiseRatio,
|
|
665
|
+
alphaEdgeSharpness: boundaryMetrics.edgeSharpness,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
if (typeof target.alphaHaloRiskMax === "number" && boundaryMetrics) {
|
|
669
|
+
if (boundaryMetrics.haloRisk > target.alphaHaloRiskMax) {
|
|
670
|
+
report.issues.push({
|
|
671
|
+
level: "error",
|
|
672
|
+
code: "alpha_halo_risk_exceeded",
|
|
673
|
+
targetId: target.id,
|
|
674
|
+
imagePath,
|
|
675
|
+
message: `Alpha halo risk ${boundaryMetrics.haloRisk.toFixed(4)} exceeds threshold ${target.alphaHaloRiskMax.toFixed(4)}.`,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (typeof target.alphaStrayNoiseMax === "number" && boundaryMetrics) {
|
|
680
|
+
if (boundaryMetrics.strayNoiseRatio > target.alphaStrayNoiseMax) {
|
|
681
|
+
report.issues.push({
|
|
682
|
+
level: "error",
|
|
683
|
+
code: "alpha_stray_noise_exceeded",
|
|
684
|
+
targetId: target.id,
|
|
685
|
+
imagePath,
|
|
686
|
+
message: `Alpha stray-noise ratio ${boundaryMetrics.strayNoiseRatio.toFixed(4)} exceeds threshold ${target.alphaStrayNoiseMax.toFixed(4)}.`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (typeof target.alphaEdgeSharpnessMin === "number" && boundaryMetrics) {
|
|
691
|
+
if (boundaryMetrics.edgeSharpness < target.alphaEdgeSharpnessMin) {
|
|
692
|
+
report.issues.push({
|
|
693
|
+
level: "error",
|
|
694
|
+
code: "alpha_edge_sharpness_too_low",
|
|
695
|
+
targetId: target.id,
|
|
696
|
+
imagePath,
|
|
697
|
+
message: `Alpha edge sharpness ${boundaryMetrics.edgeSharpness.toFixed(4)} is below threshold ${target.alphaEdgeSharpnessMin.toFixed(4)}.`,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (target.visualStylePolicy) {
|
|
702
|
+
const visualStyleMetrics = computeVisualStyleMetrics(inspected);
|
|
703
|
+
report.metrics = {
|
|
704
|
+
...report.metrics,
|
|
705
|
+
styleLineContrast: visualStyleMetrics.lineContrast,
|
|
706
|
+
styleShadingBandCount: visualStyleMetrics.shadingBandCount,
|
|
707
|
+
styleUiRectilinearity: visualStyleMetrics.uiRectilinearity,
|
|
708
|
+
};
|
|
709
|
+
if (typeof target.visualStylePolicy.lineContrastMin === "number" &&
|
|
710
|
+
visualStyleMetrics.lineContrast < target.visualStylePolicy.lineContrastMin) {
|
|
711
|
+
report.issues.push({
|
|
712
|
+
level: "error",
|
|
713
|
+
code: "style_policy_line_contrast_below_min",
|
|
714
|
+
targetId: target.id,
|
|
715
|
+
imagePath,
|
|
716
|
+
message: `Line contrast ${visualStyleMetrics.lineContrast.toFixed(4)} is below style policy minimum ${target.visualStylePolicy.lineContrastMin.toFixed(4)}.`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
if (typeof target.visualStylePolicy.shadingBandCountMax === "number" &&
|
|
720
|
+
visualStyleMetrics.shadingBandCount > target.visualStylePolicy.shadingBandCountMax) {
|
|
721
|
+
report.issues.push({
|
|
722
|
+
level: "error",
|
|
723
|
+
code: "style_policy_shading_band_count_exceeded",
|
|
724
|
+
targetId: target.id,
|
|
725
|
+
imagePath,
|
|
726
|
+
message: `Shading band count ${visualStyleMetrics.shadingBandCount} exceeds style policy maximum ${target.visualStylePolicy.shadingBandCountMax}.`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (typeof target.visualStylePolicy.uiRectilinearityMin === "number" &&
|
|
730
|
+
visualStyleMetrics.uiRectilinearity < target.visualStylePolicy.uiRectilinearityMin) {
|
|
731
|
+
report.issues.push({
|
|
732
|
+
level: "error",
|
|
733
|
+
code: "style_policy_ui_rectilinearity_below_min",
|
|
734
|
+
targetId: target.id,
|
|
735
|
+
imagePath,
|
|
736
|
+
message: `UI rectilinearity ${visualStyleMetrics.uiRectilinearity.toFixed(4)} is below style policy minimum ${target.visualStylePolicy.uiRectilinearityMin.toFixed(4)}.`,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (inspected.hasAlphaChannel) {
|
|
741
|
+
const mattingMetrics = computeMattingQualityMetrics(inspected);
|
|
742
|
+
report.metrics = {
|
|
743
|
+
...report.metrics,
|
|
744
|
+
mattingMaskCoverage: mattingMetrics.maskCoverage,
|
|
745
|
+
mattingSemiTransparencyRatio: mattingMetrics.semiTransparencyRatio,
|
|
746
|
+
mattingMaskConsistency: mattingMetrics.maskConsistency,
|
|
747
|
+
mattingHiddenRgbLeak: mattingMetrics.hiddenRgbLeak,
|
|
748
|
+
};
|
|
749
|
+
if (typeof target.mattingHiddenRgbLeakMax === "number" &&
|
|
750
|
+
mattingMetrics.hiddenRgbLeak > target.mattingHiddenRgbLeakMax) {
|
|
751
|
+
report.issues.push({
|
|
752
|
+
level: "error",
|
|
753
|
+
code: "matting_hidden_rgb_leak_exceeded",
|
|
754
|
+
targetId: target.id,
|
|
755
|
+
imagePath,
|
|
756
|
+
message: `Matting hidden RGB leak ${mattingMetrics.hiddenRgbLeak.toFixed(4)} exceeds threshold ${target.mattingHiddenRgbLeakMax.toFixed(4)}.`,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
if (typeof target.mattingMaskConsistencyMin === "number" &&
|
|
760
|
+
mattingMetrics.maskConsistency < target.mattingMaskConsistencyMin) {
|
|
761
|
+
report.issues.push({
|
|
762
|
+
level: "error",
|
|
763
|
+
code: "matting_mask_consistency_too_low",
|
|
764
|
+
targetId: target.id,
|
|
765
|
+
imagePath,
|
|
766
|
+
message: `Matting mask consistency ${mattingMetrics.maskConsistency.toFixed(4)} is below threshold ${target.mattingMaskConsistencyMin.toFixed(4)}.`,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (typeof target.mattingSemiTransparencyRatioMax === "number" &&
|
|
770
|
+
mattingMetrics.semiTransparencyRatio > target.mattingSemiTransparencyRatioMax) {
|
|
771
|
+
report.issues.push({
|
|
772
|
+
level: "error",
|
|
773
|
+
code: "matting_semi_transparency_ratio_exceeded",
|
|
774
|
+
targetId: target.id,
|
|
775
|
+
imagePath,
|
|
776
|
+
message: `Matting semi-transparency ratio ${mattingMetrics.semiTransparencyRatio.toFixed(4)} exceeds threshold ${target.mattingSemiTransparencyRatioMax.toFixed(4)}.`,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (target.tileable || typeof target.seamThreshold === "number") {
|
|
781
|
+
const seamStripPx = target.seamStripPx ?? 4;
|
|
782
|
+
const seamScore = computeSeamScore(inspected, seamStripPx);
|
|
783
|
+
report.metrics = {
|
|
784
|
+
...report.metrics,
|
|
785
|
+
seamScore,
|
|
786
|
+
seamStripPx,
|
|
787
|
+
};
|
|
788
|
+
const threshold = target.seamThreshold ?? 12;
|
|
789
|
+
if (seamScore > threshold) {
|
|
790
|
+
report.issues.push({
|
|
791
|
+
level: "error",
|
|
792
|
+
code: "tile_seam_exceeded",
|
|
793
|
+
targetId: target.id,
|
|
794
|
+
imagePath,
|
|
795
|
+
message: `Seam score ${seamScore.toFixed(2)} exceeds threshold ${threshold.toFixed(2)}.`,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (target.wrapGrid) {
|
|
800
|
+
const columns = target.wrapGrid.columns;
|
|
801
|
+
const rows = target.wrapGrid.rows;
|
|
802
|
+
report.metrics = {
|
|
803
|
+
...report.metrics,
|
|
804
|
+
wrapGridColumns: columns,
|
|
805
|
+
wrapGridRows: rows,
|
|
806
|
+
};
|
|
807
|
+
if (inspected.width % columns !== 0 || inspected.height % rows !== 0) {
|
|
808
|
+
report.issues.push({
|
|
809
|
+
level: "error",
|
|
810
|
+
code: "wrap_grid_size_mismatch",
|
|
811
|
+
targetId: target.id,
|
|
812
|
+
imagePath,
|
|
813
|
+
message: `Image size ${inspected.width}x${inspected.height} is not divisible by wrapGrid ${columns}x${rows}.`,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
const seamStripPx = target.wrapGrid.seamStripPx ?? target.seamStripPx ?? 4;
|
|
818
|
+
const seamScore = computeWrapGridSeamScore(inspected, columns, rows, seamStripPx);
|
|
819
|
+
if (typeof seamScore === "number") {
|
|
820
|
+
report.metrics = {
|
|
821
|
+
...report.metrics,
|
|
822
|
+
wrapGridSeamScore: seamScore,
|
|
823
|
+
wrapGridSeamStripPx: seamStripPx,
|
|
824
|
+
};
|
|
825
|
+
const threshold = target.wrapGrid.seamThreshold ?? target.seamThreshold ?? 12;
|
|
826
|
+
if (seamScore > threshold) {
|
|
827
|
+
report.issues.push({
|
|
828
|
+
level: "error",
|
|
829
|
+
code: "wrap_grid_seam_exceeded",
|
|
830
|
+
targetId: target.id,
|
|
831
|
+
imagePath,
|
|
832
|
+
message: `Wrap-grid seam score ${seamScore.toFixed(2)} exceeds threshold ${threshold.toFixed(2)}.`,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const topology = target.wrapGrid.topology;
|
|
837
|
+
if (topology) {
|
|
838
|
+
const threshold = topology.maxMismatchRatio ?? 0;
|
|
839
|
+
const colorTolerance = topology.colorTolerance ?? 0;
|
|
840
|
+
const topologyResult = evaluateWrapGridTopology({
|
|
841
|
+
inspected,
|
|
842
|
+
columns,
|
|
843
|
+
rows,
|
|
844
|
+
mode: topology.mode,
|
|
845
|
+
colorTolerance,
|
|
846
|
+
});
|
|
847
|
+
if (topologyResult) {
|
|
848
|
+
report.metrics = {
|
|
849
|
+
...report.metrics,
|
|
850
|
+
wrapGridTopologyComparisons: topologyResult.comparisons,
|
|
851
|
+
wrapGridTopologyMismatchRatio: topologyResult.mismatchRatio,
|
|
852
|
+
wrapGridTopologyThreshold: threshold,
|
|
853
|
+
wrapGridTopologyColorTolerance: colorTolerance,
|
|
854
|
+
};
|
|
855
|
+
if (topologyResult.mismatchRatio > threshold) {
|
|
856
|
+
report.issues.push({
|
|
857
|
+
level: "error",
|
|
858
|
+
code: resolveWrapGridTopologyIssueCode(topologyResult.mode),
|
|
859
|
+
targetId: target.id,
|
|
860
|
+
imagePath,
|
|
861
|
+
message: `Wrap-grid topology (${topologyResult.mode}) mismatch ratio ${topologyResult.mismatchRatio.toFixed(4)} exceeds threshold ${threshold.toFixed(4)}.`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (target.palette?.mode === "max-colors") {
|
|
869
|
+
const colors = collectDistinctColors(inspected);
|
|
870
|
+
report.metrics = {
|
|
871
|
+
...report.metrics,
|
|
872
|
+
distinctColors: colors.size,
|
|
873
|
+
};
|
|
874
|
+
const maxColors = target.palette.maxColors ?? 256;
|
|
875
|
+
if (colors.size > maxColors) {
|
|
876
|
+
report.issues.push({
|
|
877
|
+
level: "error",
|
|
878
|
+
code: "palette_max_colors_exceeded",
|
|
879
|
+
targetId: target.id,
|
|
880
|
+
imagePath,
|
|
881
|
+
message: `Distinct colors ${colors.size} exceeds max ${maxColors}.`,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (target.palette?.mode === "exact") {
|
|
886
|
+
const allowed = new Set((target.palette.colors ?? []).map((color) => hexToPackedColor(color)));
|
|
887
|
+
const compliance = computeExactPaletteCompliance(inspected, allowed);
|
|
888
|
+
const requiredCompliance = requiredExactPaletteCompliance(target);
|
|
889
|
+
report.metrics = {
|
|
890
|
+
...report.metrics,
|
|
891
|
+
paletteCompliance: compliance.compliance,
|
|
892
|
+
distinctColors: compliance.distinctColors,
|
|
893
|
+
};
|
|
894
|
+
if (compliance.compliance < requiredCompliance) {
|
|
895
|
+
if (target.palette.strict === true) {
|
|
896
|
+
report.issues.push({
|
|
897
|
+
level: "error",
|
|
898
|
+
code: "palette_strict_noncompliant",
|
|
899
|
+
targetId: target.id,
|
|
900
|
+
imagePath,
|
|
901
|
+
message: `Strict exact palette mode requires 100% compliance, but got ${(compliance.compliance * 100).toFixed(1)}%.`,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
report.issues.push({
|
|
906
|
+
level: "error",
|
|
907
|
+
code: "palette_compliance_too_low",
|
|
908
|
+
targetId: target.id,
|
|
909
|
+
imagePath,
|
|
910
|
+
message: `Palette compliance ${(compliance.compliance * 100).toFixed(1)}% is below required ${(requiredCompliance * 100).toFixed(0)}%.`,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return report;
|
|
916
|
+
}
|
|
917
|
+
export async function runImageAcceptanceChecks(params) {
|
|
918
|
+
const strict = params.strict ?? true;
|
|
919
|
+
const runtimeTargets = params.targets.filter((target) => !target.catalogDisabled);
|
|
920
|
+
const items = await Promise.all(runtimeTargets.map((target) => evaluateImageAcceptance(target, params.imagesDir)));
|
|
921
|
+
const packInvariantResult = await runPackInvariantChecks({
|
|
922
|
+
targets: params.targets,
|
|
923
|
+
items,
|
|
924
|
+
imagesDir: params.imagesDir,
|
|
925
|
+
});
|
|
926
|
+
const itemByTargetId = new Map(items.map((item) => [item.targetId, item]));
|
|
927
|
+
for (const targetIssue of packInvariantResult.targetIssues) {
|
|
928
|
+
const item = itemByTargetId.get(targetIssue.targetId);
|
|
929
|
+
if (!item) {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
item.issues.push({
|
|
933
|
+
level: targetIssue.level,
|
|
934
|
+
code: targetIssue.code,
|
|
935
|
+
targetId: targetIssue.targetId,
|
|
936
|
+
imagePath: item.imagePath,
|
|
937
|
+
message: targetIssue.message,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
const errors = items.reduce((count, item) => count + item.issues.filter((issue) => issue.level === "error").length, 0);
|
|
941
|
+
const warnings = items.reduce((count, item) => count + item.issues.filter((issue) => issue.level === "warning").length, 0);
|
|
942
|
+
const failed = items.filter((item) => item.issues.some((issue) => issue.level === "error")).length;
|
|
943
|
+
return {
|
|
944
|
+
generatedAt: new Date().toISOString(),
|
|
945
|
+
imagesDir: params.imagesDir,
|
|
946
|
+
strict,
|
|
947
|
+
total: items.length,
|
|
948
|
+
passed: items.length - failed,
|
|
949
|
+
failed,
|
|
950
|
+
errors,
|
|
951
|
+
warnings,
|
|
952
|
+
items,
|
|
953
|
+
...(packInvariantResult.summary ? { packInvariants: packInvariantResult.summary } : {}),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
export function assertImageAcceptanceReport(report) {
|
|
957
|
+
if (report.strict && report.errors > 0) {
|
|
958
|
+
const examples = report.items
|
|
959
|
+
.flatMap((item) => item.issues)
|
|
960
|
+
.filter((issue) => issue.level === "error")
|
|
961
|
+
.slice(0, 5)
|
|
962
|
+
.map((issue) => `${issue.targetId}: ${issue.code}`)
|
|
963
|
+
.join(", ");
|
|
964
|
+
throw new Error(`Image acceptance failed with ${report.errors} error(s). ${examples}`.trim());
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
//# sourceMappingURL=imageAcceptance.js.map
|