@prosopo/puzzle-assets 0.1.4 → 0.2.1
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/.turbo/turbo-build$colon$cjs.log +13 -15
- package/.turbo/turbo-build$colon$tsc.log +5 -8
- package/.turbo/turbo-build.log +15 -20
- package/CHANGELOG.md +86 -0
- package/LICENSE +201 -0
- package/dist/browser.d.ts +3 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -4
- package/dist/browser.js.map +1 -1
- package/dist/cjs/compose.cjs +117 -1
- package/dist/cjs/contrast.cjs +142 -0
- package/dist/cjs/index.cjs +24 -6
- package/dist/cjs/notch.cjs +115 -93
- package/dist/cjs/palette.cjs +36 -3
- package/dist/compose.d.ts +4 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +117 -2
- package/dist/compose.js.map +1 -1
- package/dist/contrast.d.ts +9 -0
- package/dist/contrast.d.ts.map +1 -0
- package/dist/contrast.js +137 -0
- package/dist/contrast.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -7
- package/dist/index.js.map +1 -1
- package/dist/notch.d.ts.map +1 -1
- package/dist/notch.js +115 -93
- package/dist/notch.js.map +1 -1
- package/dist/palette.d.ts +1 -0
- package/dist/palette.d.ts.map +1 -1
- package/dist/palette.js +36 -4
- package/dist/palette.js.map +1 -1
- package/dist/prng.d.ts.map +1 -1
- package/dist/prng.js.map +1 -1
- package/dist/tests/pieceReadability.unit.test.d.ts +2 -0
- package/dist/tests/pieceReadability.unit.test.d.ts.map +1 -0
- package/dist/tests/pieceReadability.unit.test.js +320 -0
- package/dist/tests/pieceReadability.unit.test.js.map +1 -0
- package/dist/tests/renderReplay.unit.test.d.ts +2 -0
- package/dist/tests/renderReplay.unit.test.d.ts.map +1 -0
- package/dist/tests/renderReplay.unit.test.js +77 -0
- package/dist/tests/renderReplay.unit.test.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +59 -60
- package/src/background.ts +0 -189
- package/src/browser.ts +0 -100
- package/src/compose.ts +0 -210
- package/src/decoys.ts +0 -191
- package/src/encode.ts +0 -59
- package/src/index.ts +0 -137
- package/src/notch.ts +0 -223
- package/src/palette.ts +0 -110
- package/src/prng.ts +0 -95
- package/src/tests/puzzleAssets.unit.test.ts +0 -438
- package/src/types.ts +0 -93
- package/tsconfig.cjs.json +0 -14
- package/tsconfig.json +0 -19
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
package/package.json
CHANGED
|
@@ -1,61 +1,60 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
2
|
+
"name": "@prosopo/puzzle-assets",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Procedural imagery for the slider puzzle captcha",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": "^24"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/cjs/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./browser": {
|
|
18
|
+
"types": "./dist/browser.d.ts",
|
|
19
|
+
"import": "./dist/browser.js",
|
|
20
|
+
"require": "./dist/cjs/browser.cjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/prosopo/captcha.git",
|
|
26
|
+
"directory": "packages/puzzle-assets"
|
|
27
|
+
},
|
|
28
|
+
"author": "Prosopo Limited",
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/prosopo/captcha/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/prosopo/captcha#readme",
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"sharp": "0.35.4"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@prosopo/config": "3.4.3",
|
|
39
|
+
"@types/node": "22.10.2",
|
|
40
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
41
|
+
"del-cli": "6.0.0",
|
|
42
|
+
"dotenv": "16.4.5",
|
|
43
|
+
"tslib": "2.7.0",
|
|
44
|
+
"tsx": "4.20.3",
|
|
45
|
+
"typescript": "5.6.2",
|
|
46
|
+
"vite": "8.1.5",
|
|
47
|
+
"vitest": "4.1.11"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"scripts": {
|
|
51
|
+
"clean": "del-cli --verbose dist tsconfig.tsbuildinfo",
|
|
52
|
+
"build": "pnpm run build:cross-env --mode ${NODE_ENV:-development}",
|
|
53
|
+
"build:cross-env": "vite build --config vite.esm.config.ts",
|
|
54
|
+
"build:tsc": "tsc --build --verbose",
|
|
55
|
+
"build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
|
|
56
|
+
"preview": "tsx scripts/preview.ts",
|
|
57
|
+
"test": "NODE_ENV=${NODE_ENV:-test}; pnpm exec vitest run --config ./vite.test.config.ts",
|
|
58
|
+
"typecheck": "tsc --project tsconfig.types.json"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/background.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import { type Rgb, drawPalette } from "./palette.js";
|
|
16
|
-
import type { Prng } from "./prng.js";
|
|
17
|
-
import type { RgbaImage } from "./types.js";
|
|
18
|
-
|
|
19
|
-
interface MeshPoint {
|
|
20
|
-
x: number;
|
|
21
|
-
y: number;
|
|
22
|
-
colour: Rgb;
|
|
23
|
-
/** Falloff radius. Small values give tight colour fields, large ones wash. */
|
|
24
|
-
sigma: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface Blob {
|
|
28
|
-
x: number;
|
|
29
|
-
y: number;
|
|
30
|
-
sigma: number;
|
|
31
|
-
colour: Rgb;
|
|
32
|
-
alpha: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Grain amplitude, in 0-255 units, applied per channel.
|
|
37
|
-
*
|
|
38
|
-
* This is the one knob serving both goals at once. Visually it reads as film
|
|
39
|
-
* grain and stops the mesh gradient banding on 8-bit displays. Structurally it
|
|
40
|
-
* raises the noise floor an edge detector has to clear to find the notch
|
|
41
|
-
* boundary — a perfectly smooth gradient would hand the target over to a Canny
|
|
42
|
-
* filter. Too high and the image looks like TV static, so it stays subtle.
|
|
43
|
-
*/
|
|
44
|
-
const GRAIN_AMPLITUDE = 5;
|
|
45
|
-
|
|
46
|
-
const VIGNETTE_STRENGTH = 0.16;
|
|
47
|
-
|
|
48
|
-
const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Smooth multi-colour blend: every pixel is a Gaussian-weighted mix of the
|
|
52
|
-
* control points, giving the "mesh gradient" look — broad soft colour fields
|
|
53
|
-
* with no hard edges anywhere.
|
|
54
|
-
*
|
|
55
|
-
* The falloff has to be local. Inverse-distance weighting (1/d^2) looks right
|
|
56
|
-
* on paper but at 300x200 every control point is a comparable distance from
|
|
57
|
-
* every pixel, so the weights come out near-equal and the whole frame collapses
|
|
58
|
-
* to the palette's mean — a flat grey-ish wash. A Gaussian with sigma around a
|
|
59
|
-
* third of the frame keeps each point dominant near itself and still blends
|
|
60
|
-
* smoothly in between.
|
|
61
|
-
*/
|
|
62
|
-
const meshColourAt = (
|
|
63
|
-
points: readonly MeshPoint[],
|
|
64
|
-
x: number,
|
|
65
|
-
y: number,
|
|
66
|
-
): Rgb => {
|
|
67
|
-
let wSum = 0;
|
|
68
|
-
let r = 0;
|
|
69
|
-
let g = 0;
|
|
70
|
-
let b = 0;
|
|
71
|
-
for (const point of points) {
|
|
72
|
-
const dx = x - point.x;
|
|
73
|
-
const dy = y - point.y;
|
|
74
|
-
const w = Math.exp(-(dx * dx + dy * dy) / (2 * point.sigma * point.sigma));
|
|
75
|
-
wSum += w;
|
|
76
|
-
r += point.colour.r * w;
|
|
77
|
-
g += point.colour.g * w;
|
|
78
|
-
b += point.colour.b * w;
|
|
79
|
-
}
|
|
80
|
-
// Every point can be far away near a corner; fall back to the nearest.
|
|
81
|
-
if (wSum < 1e-6) {
|
|
82
|
-
let best = points[0];
|
|
83
|
-
if (!best) throw new Error("puzzle-assets: no mesh points");
|
|
84
|
-
let bestD = Number.POSITIVE_INFINITY;
|
|
85
|
-
for (const point of points) {
|
|
86
|
-
const d = (x - point.x) ** 2 + (y - point.y) ** 2;
|
|
87
|
-
if (d < bestD) {
|
|
88
|
-
bestD = d;
|
|
89
|
-
best = point;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return best.colour;
|
|
93
|
-
}
|
|
94
|
-
return { r: r / wSum, g: g / wSum, b: b / wSum };
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Synthesise a background. Deterministic in `prng`, so the same seed always
|
|
99
|
-
* yields the same picture — see the seed-secrecy note in prng.ts for why that
|
|
100
|
-
* determinism is safe only while the seed stays on the provider.
|
|
101
|
-
*/
|
|
102
|
-
export const generateBackground = (
|
|
103
|
-
prng: Prng,
|
|
104
|
-
width: number,
|
|
105
|
-
height: number,
|
|
106
|
-
): RgbaImage => {
|
|
107
|
-
const pointCount = prng.int(4, 6);
|
|
108
|
-
// Drawn to length: drawPalette spreads hues evenly across the band, so
|
|
109
|
-
// taking a prefix of a longer draw would cover only part of it and leave
|
|
110
|
-
// the mesh monotone.
|
|
111
|
-
const palette = drawPalette(prng, pointCount);
|
|
112
|
-
const blobPalette = drawPalette(prng, 3);
|
|
113
|
-
|
|
114
|
-
const minDimension = Math.min(width, height);
|
|
115
|
-
const points: MeshPoint[] = [];
|
|
116
|
-
for (let i = 0; i < pointCount; i++) {
|
|
117
|
-
const colour = palette[i];
|
|
118
|
-
if (!colour) throw new Error("puzzle-assets: palette underflow");
|
|
119
|
-
points.push({
|
|
120
|
-
// Allow control points slightly outside the frame so the gradient
|
|
121
|
-
// keeps moving at the edges instead of pooling into flat corners.
|
|
122
|
-
x: prng.range(-0.15, 1.15) * width,
|
|
123
|
-
y: prng.range(-0.15, 1.15) * height,
|
|
124
|
-
colour,
|
|
125
|
-
sigma: prng.range(0.26, 0.48) * minDimension,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const blobs: Blob[] = [];
|
|
130
|
-
const blobCount = prng.int(2, 4);
|
|
131
|
-
for (let i = 0; i < blobCount; i++) {
|
|
132
|
-
const colour = blobPalette[i % blobPalette.length];
|
|
133
|
-
if (!colour) throw new Error("puzzle-assets: palette underflow");
|
|
134
|
-
blobs.push({
|
|
135
|
-
x: prng.range(0.1, 0.9) * width,
|
|
136
|
-
y: prng.range(0.1, 0.9) * height,
|
|
137
|
-
sigma: prng.range(0.18, 0.38) * minDimension,
|
|
138
|
-
colour,
|
|
139
|
-
alpha: prng.range(0.16, 0.32),
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const data = Buffer.alloc(width * height * 4);
|
|
144
|
-
const cx = width / 2;
|
|
145
|
-
const cy = height / 2;
|
|
146
|
-
const maxRadius = Math.sqrt(cx * cx + cy * cy);
|
|
147
|
-
|
|
148
|
-
for (let y = 0; y < height; y++) {
|
|
149
|
-
for (let x = 0; x < width; x++) {
|
|
150
|
-
const base = meshColourAt(points, x, y);
|
|
151
|
-
let r = base.r;
|
|
152
|
-
let g = base.g;
|
|
153
|
-
let b = base.b;
|
|
154
|
-
|
|
155
|
-
// Soft radial depth. Gaussian falloff means no visible boundary.
|
|
156
|
-
for (const blob of blobs) {
|
|
157
|
-
const dx = x - blob.x;
|
|
158
|
-
const dy = y - blob.y;
|
|
159
|
-
const a =
|
|
160
|
-
blob.alpha *
|
|
161
|
-
Math.exp(-(dx * dx + dy * dy) / (2 * blob.sigma * blob.sigma));
|
|
162
|
-
r += (blob.colour.r - r) * a;
|
|
163
|
-
g += (blob.colour.g - g) * a;
|
|
164
|
-
b += (blob.colour.b - b) * a;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Vignette: darkens the corners a little, which reads as depth and
|
|
168
|
-
// keeps attention in the middle where the puzzle happens.
|
|
169
|
-
const dxc = x - cx;
|
|
170
|
-
const dyc = y - cy;
|
|
171
|
-
const vignette =
|
|
172
|
-
1 -
|
|
173
|
-
VIGNETTE_STRENGTH * ((dxc * dxc + dyc * dyc) / (maxRadius * maxRadius));
|
|
174
|
-
r *= vignette;
|
|
175
|
-
g *= vignette;
|
|
176
|
-
b *= vignette;
|
|
177
|
-
|
|
178
|
-
const grain = (prng.next() - 0.5) * 2 * GRAIN_AMPLITUDE;
|
|
179
|
-
|
|
180
|
-
const i = (y * width + x) * 4;
|
|
181
|
-
data[i] = clamp255(Math.round(r + grain));
|
|
182
|
-
data[i + 1] = clamp255(Math.round(g + grain));
|
|
183
|
-
data[i + 2] = clamp255(Math.round(b + grain));
|
|
184
|
-
data[i + 3] = 255;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return { data, width, height };
|
|
189
|
-
};
|
package/src/browser.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
// Browser-safe surface: same drawing pipeline as the provider, minus the
|
|
16
|
-
// sharp-based WebP encoder. Consumers paint the returned RGBA buffers onto
|
|
17
|
-
// a <canvas> directly.
|
|
18
|
-
|
|
19
|
-
import { generateBackground } from "./background.js";
|
|
20
|
-
import { cutNotch } from "./compose.js";
|
|
21
|
-
import { paintDecoys } from "./decoys.js";
|
|
22
|
-
import { createNotchShape } from "./notch.js";
|
|
23
|
-
import { createPrng, createSeed } from "./prng.js";
|
|
24
|
-
import type {
|
|
25
|
-
NotchPlacement,
|
|
26
|
-
PuzzleGeometry,
|
|
27
|
-
PuzzleRenderSettings,
|
|
28
|
-
RgbaImage,
|
|
29
|
-
} from "./types.js";
|
|
30
|
-
|
|
31
|
-
export { createPrng, createSeed, SEED_BYTES } from "./prng.js";
|
|
32
|
-
export { generateBackground } from "./background.js";
|
|
33
|
-
export { paintDecoys, paintDecoyPiece } from "./decoys.js";
|
|
34
|
-
export type {
|
|
35
|
-
NotchPlacement,
|
|
36
|
-
PuzzleGeometry,
|
|
37
|
-
PuzzleRenderSettings,
|
|
38
|
-
RenderedPuzzle,
|
|
39
|
-
RgbaImage,
|
|
40
|
-
} from "./types.js";
|
|
41
|
-
|
|
42
|
-
export const DEFAULT_GEOMETRY: PuzzleGeometry = {
|
|
43
|
-
width: 300,
|
|
44
|
-
height: 200,
|
|
45
|
-
notchSize: 44,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export const DEFAULT_RENDER_SETTINGS: PuzzleRenderSettings = {
|
|
49
|
-
decoyCount: 5,
|
|
50
|
-
decoyEdgeDarkness: 20,
|
|
51
|
-
decoyBodyBrightness: 4,
|
|
52
|
-
holeDarken: 0.55,
|
|
53
|
-
decoyHoleDarken: 0.7,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export interface RenderedPuzzleRgba {
|
|
57
|
-
background: RgbaImage;
|
|
58
|
-
piece: RgbaImage;
|
|
59
|
-
pieceSize: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Same pipeline as `renderPuzzle` in index.ts but returns raw RGBA rather
|
|
64
|
-
* than WebP-encoded buffers, so browser consumers can `putImageData` onto
|
|
65
|
-
* a canvas without needing sharp.
|
|
66
|
-
*/
|
|
67
|
-
export const renderPuzzleToRgba = (
|
|
68
|
-
background: RgbaImage,
|
|
69
|
-
placement: NotchPlacement,
|
|
70
|
-
geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
|
|
71
|
-
settings: PuzzleRenderSettings = DEFAULT_RENDER_SETTINGS,
|
|
72
|
-
): RenderedPuzzleRgba => {
|
|
73
|
-
const prng = createPrng(createSeed());
|
|
74
|
-
const pieceSize = geometry.pieceSize ?? geometry.notchSize;
|
|
75
|
-
paintDecoys(
|
|
76
|
-
background,
|
|
77
|
-
prng,
|
|
78
|
-
settings.decoyCount,
|
|
79
|
-
pieceSize,
|
|
80
|
-
placement,
|
|
81
|
-
settings.decoyEdgeDarkness,
|
|
82
|
-
settings.decoyBodyBrightness,
|
|
83
|
-
settings.decoyHoleDarken,
|
|
84
|
-
);
|
|
85
|
-
const shape = createNotchShape(prng, pieceSize);
|
|
86
|
-
const { background: cut, piece } = cutNotch(
|
|
87
|
-
prng,
|
|
88
|
-
background,
|
|
89
|
-
shape,
|
|
90
|
-
pieceSize,
|
|
91
|
-
placement,
|
|
92
|
-
settings.holeDarken,
|
|
93
|
-
);
|
|
94
|
-
return { background: cut, piece, pieceSize };
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
export const createBackground = (
|
|
98
|
-
geometry: PuzzleGeometry = DEFAULT_GEOMETRY,
|
|
99
|
-
): RgbaImage =>
|
|
100
|
-
generateBackground(createPrng(createSeed()), geometry.width, geometry.height);
|
package/src/compose.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import { type NotchShape, coverageFromDistance } from "./notch.js";
|
|
16
|
-
import type { Prng } from "./prng.js";
|
|
17
|
-
import type { NotchPlacement, RgbaImage } from "./types.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Per-pixel noise added to the piece, in 0-255 units.
|
|
21
|
-
*
|
|
22
|
-
* This is not decoration. The piece is cut from the background, so a
|
|
23
|
-
* pixel-exact copy would let an attacker slide the piece over the background
|
|
24
|
-
* with normalised cross-correlation and land on the target in one pass —
|
|
25
|
-
* which would make the whole server-rendering exercise worthless. The piece is
|
|
26
|
-
* therefore brightness-shifted and re-noised so it correlates well with a
|
|
27
|
-
* region of the background but never perfectly, and the background gets fresh
|
|
28
|
-
* noise around the cut so the seam is not a clean template either.
|
|
29
|
-
*/
|
|
30
|
-
const PIECE_NOISE = 7;
|
|
31
|
-
const PIECE_BRIGHTNESS_RANGE: [number, number] = [-8, 8];
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Disturbance ring outside the cut, in px. The ring exists so an edge detector
|
|
35
|
-
* cannot lock onto a clean gradient discontinuity along the seam. The noise
|
|
36
|
-
* tapers linearly to zero at the outer edge so it fades into the ambient
|
|
37
|
-
* background grain instead of terminating in a visible border.
|
|
38
|
-
*/
|
|
39
|
-
const SEAM_NOISE_RADIUS = 3;
|
|
40
|
-
/**
|
|
41
|
-
* Peak amplitude at the seam itself, in 0-255 units. Kept close to the
|
|
42
|
-
* background grain amplitude so the ring reads as continuation of the grain
|
|
43
|
-
* rather than as a halo around the hole.
|
|
44
|
-
*/
|
|
45
|
-
const SEAM_NOISE = 4;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Depth cue on the hole. HOLE_INNER_SHADOW is a fixed within-primitive
|
|
49
|
-
* ratio, but HOLE_DARKEN is caller-provided so operators can tune how
|
|
50
|
-
* obvious the target is versus the decoys.
|
|
51
|
-
*/
|
|
52
|
-
const HOLE_INNER_SHADOW = 0.45;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Depth cue on the piece itself. A subtle brightness rise just inside the
|
|
56
|
-
* silhouette edge, so the piece reads as raised rather than as a flat sticker.
|
|
57
|
-
* Kept low deliberately — a stronger rim starts to look like a drawn border
|
|
58
|
-
* against the darkened cutout it drops into.
|
|
59
|
-
*/
|
|
60
|
-
const PIECE_EDGE_LIGHT = 8;
|
|
61
|
-
|
|
62
|
-
const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v);
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Sample the background at `(x, y)`. Returns `null` when the coordinate
|
|
66
|
-
* sits outside the frame — the piece is clipped at the boundary rather
|
|
67
|
-
* than filled from a clamped edge pixel, so a piece whose placement
|
|
68
|
-
* overhangs the frame simply loses those pixels.
|
|
69
|
-
*/
|
|
70
|
-
const sampleBackground = (
|
|
71
|
-
image: RgbaImage,
|
|
72
|
-
x: number,
|
|
73
|
-
y: number,
|
|
74
|
-
): [number, number, number] | null => {
|
|
75
|
-
if (x < 0 || y < 0 || x >= image.width || y >= image.height) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
const i = (y * image.width + x) * 4;
|
|
79
|
-
return [image.data[i] ?? 0, image.data[i + 1] ?? 0, image.data[i + 2] ?? 0];
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export interface CutResult {
|
|
83
|
-
/** The background, mutated in place, with the hole cut into it. */
|
|
84
|
-
background: RgbaImage;
|
|
85
|
-
/** The draggable piece, on transparency. */
|
|
86
|
-
piece: RgbaImage;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Cut the notch out of `background` and produce the matching piece.
|
|
91
|
-
*
|
|
92
|
-
* `background` is mutated in place — it is a freshly generated, single-use
|
|
93
|
-
* image, so there is nothing to preserve.
|
|
94
|
-
*/
|
|
95
|
-
export const cutNotch = (
|
|
96
|
-
prng: Prng,
|
|
97
|
-
background: RgbaImage,
|
|
98
|
-
shape: NotchShape,
|
|
99
|
-
size: number,
|
|
100
|
-
placement: NotchPlacement,
|
|
101
|
-
holeDarken: number,
|
|
102
|
-
): CutResult => {
|
|
103
|
-
const half = size / 2;
|
|
104
|
-
const left = Math.round(placement.targetX - half);
|
|
105
|
-
const top = Math.round(placement.targetY - half);
|
|
106
|
-
|
|
107
|
-
const pieceData = Buffer.alloc(size * size * 4);
|
|
108
|
-
const brightness = prng.range(
|
|
109
|
-
PIECE_BRIGHTNESS_RANGE[0],
|
|
110
|
-
PIECE_BRIGHTNESS_RANGE[1],
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
// Pass 1: build the piece from the pixels about to be removed.
|
|
114
|
-
for (let ly = 0; ly < size; ly++) {
|
|
115
|
-
for (let lx = 0; lx < size; lx++) {
|
|
116
|
-
const d = shape.distance(lx, ly);
|
|
117
|
-
const coverage = coverageFromDistance(d);
|
|
118
|
-
const pi = (ly * size + lx) * 4;
|
|
119
|
-
|
|
120
|
-
if (coverage <= 0) {
|
|
121
|
-
pieceData[pi + 3] = 0;
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const sample = sampleBackground(background, left + lx, top + ly);
|
|
126
|
-
if (sample === null) {
|
|
127
|
-
// Piece extends past the frame — leave this pixel transparent
|
|
128
|
-
// so the overhang doesn't smear the edge colour outward.
|
|
129
|
-
pieceData[pi + 3] = 0;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const [r, g, b] = sample;
|
|
133
|
-
|
|
134
|
-
// A bright rim just inside the edge makes the piece read as a
|
|
135
|
-
// raised object rather than a flat sticker.
|
|
136
|
-
const edgeLight = d > -2 ? PIECE_EDGE_LIGHT * (1 + d / 2) : 0;
|
|
137
|
-
const noise = (prng.next() - 0.5) * 2 * PIECE_NOISE;
|
|
138
|
-
const delta = brightness + edgeLight + noise;
|
|
139
|
-
|
|
140
|
-
pieceData[pi] = clamp255(Math.round(r + delta));
|
|
141
|
-
pieceData[pi + 1] = clamp255(Math.round(g + delta));
|
|
142
|
-
pieceData[pi + 2] = clamp255(Math.round(b + delta));
|
|
143
|
-
pieceData[pi + 3] = Math.round(coverage * 255);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Pass 2: cut the hole, with an inner shadow for depth, and disturb a ring
|
|
148
|
-
// of pixels around the seam.
|
|
149
|
-
for (let ly = -SEAM_NOISE_RADIUS; ly < size + SEAM_NOISE_RADIUS; ly++) {
|
|
150
|
-
for (let lx = -SEAM_NOISE_RADIUS; lx < size + SEAM_NOISE_RADIUS; lx++) {
|
|
151
|
-
const x = left + lx;
|
|
152
|
-
const y = top + ly;
|
|
153
|
-
if (x < 0 || y < 0 || x >= background.width || y >= background.height) {
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const bi = (y * background.width + x) * 4;
|
|
157
|
-
const d = shape.distance(lx, ly);
|
|
158
|
-
const coverage = coverageFromDistance(d);
|
|
159
|
-
|
|
160
|
-
if (coverage > 0) {
|
|
161
|
-
const r = background.data[bi] ?? 0;
|
|
162
|
-
const g = background.data[bi + 1] ?? 0;
|
|
163
|
-
const b = background.data[bi + 2] ?? 0;
|
|
164
|
-
|
|
165
|
-
// Darken towards the hole, deeper near the edges so the cut has
|
|
166
|
-
// an inner shadow rather than reading as a flat grey patch.
|
|
167
|
-
const edge = d > -3 ? HOLE_INNER_SHADOW * (1 + d / 3) : 0;
|
|
168
|
-
const factor = holeDarken * (1 - edge);
|
|
169
|
-
const mix = coverage;
|
|
170
|
-
|
|
171
|
-
background.data[bi] = clamp255(
|
|
172
|
-
Math.round(r * (1 - mix) + r * factor * mix),
|
|
173
|
-
);
|
|
174
|
-
background.data[bi + 1] = clamp255(
|
|
175
|
-
Math.round(g * (1 - mix) + g * factor * mix),
|
|
176
|
-
);
|
|
177
|
-
background.data[bi + 2] = clamp255(
|
|
178
|
-
Math.round(b * (1 - mix) + b * factor * mix),
|
|
179
|
-
);
|
|
180
|
-
} else if (d < SEAM_NOISE_RADIUS) {
|
|
181
|
-
// Just outside the cut: re-noise so the seam is not a clean
|
|
182
|
-
// gradient discontinuity for an edge detector to lock onto.
|
|
183
|
-
// Falloff: full amplitude at the seam, zero at the outer edge
|
|
184
|
-
// of the ring, so the disturbance blends into ambient grain
|
|
185
|
-
// instead of terminating in a visible halo.
|
|
186
|
-
const falloff = 1 - d / SEAM_NOISE_RADIUS;
|
|
187
|
-
const noise = (prng.next() - 0.5) * 2 * SEAM_NOISE * falloff;
|
|
188
|
-
background.data[bi] = clamp255(
|
|
189
|
-
Math.round((background.data[bi] ?? 0) + noise),
|
|
190
|
-
);
|
|
191
|
-
background.data[bi + 1] = clamp255(
|
|
192
|
-
Math.round((background.data[bi + 1] ?? 0) + noise),
|
|
193
|
-
);
|
|
194
|
-
background.data[bi + 2] = clamp255(
|
|
195
|
-
Math.round((background.data[bi + 2] ?? 0) + noise),
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Drop shadow is applied client-side via CSS `filter: drop-shadow` on the
|
|
202
|
-
// piece element — see PuzzleCanvas.tsx. Baking one into the piece pixels
|
|
203
|
-
// stacked with the CSS shadow read as a visible border once lossless WebP
|
|
204
|
-
// stopped smudging the baked version away.
|
|
205
|
-
|
|
206
|
-
return {
|
|
207
|
-
background,
|
|
208
|
-
piece: { data: pieceData, width: size, height: size },
|
|
209
|
-
};
|
|
210
|
-
};
|