dsh-clone 0.0.0-stage → 0.2.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/ACCEPTABLE-USE.md +28 -0
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/client.js +690 -0
- package/cordis.patch.yml +9 -0
- package/docs/assets/icon-180.png +0 -0
- package/icon.svg +29 -0
- package/index.js +626 -0
- package/index.json +75 -0
- package/lib/engine.js +999 -0
- package/lib/parse.js +58 -0
- package/lib/png.js +175 -0
- package/lib/preview.js +141 -0
- package/lib/prompts.js +118 -0
- package/lib/rank.js +72 -0
- package/lib/render.js +148 -0
- package/lib/score-lane.js +38 -0
- package/lib/score.js +282 -0
- package/lib/util.js +85 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
- package/screenshots.json +6 -0
package/lib/render.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering a lane's page to a PNG, using a browser the machine already has.
|
|
3
|
+
*
|
|
4
|
+
* dsh-clone does not bundle or download a browser. It looks for the Chromium
|
|
5
|
+
* family that people already installed (Chrome, Chromium, Edge, Brave), drives
|
|
6
|
+
* it in one-shot headless screenshot mode, and treats a missing browser as a
|
|
7
|
+
* normal state: the panel then shows the live page and the reader picks.
|
|
8
|
+
*
|
|
9
|
+
* Detection is cached for the life of the plugin, and a failed render only
|
|
10
|
+
* costs the score, never the lane.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
15
|
+
import { pathToFileURL } from 'node:url'
|
|
16
|
+
|
|
17
|
+
import { decodePng } from './png.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Browsers that implement `--headless=new --screenshot`, in the order they are
|
|
21
|
+
* probed: an explicit override first, then the per-platform install locations,
|
|
22
|
+
* then bare names resolved from PATH.
|
|
23
|
+
*/
|
|
24
|
+
const CANDIDATES = [
|
|
25
|
+
process.env.DSH_CLONE_BROWSER,
|
|
26
|
+
// macOS
|
|
27
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
28
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
29
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
30
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
31
|
+
// Windows (a large share of Harness users are on Windows)
|
|
32
|
+
process.env.LOCALAPPDATA === undefined ? undefined : `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
33
|
+
process.env.LOCALAPPDATA === undefined ? undefined : `${process.env.LOCALAPPDATA}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
34
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
35
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
36
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
37
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
38
|
+
'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe',
|
|
39
|
+
// Linux
|
|
40
|
+
'/usr/bin/google-chrome',
|
|
41
|
+
'/usr/bin/google-chrome-stable',
|
|
42
|
+
'/usr/bin/chromium',
|
|
43
|
+
'/usr/bin/chromium-browser',
|
|
44
|
+
'/snap/bin/chromium',
|
|
45
|
+
// PATH names, for anything installed elsewhere
|
|
46
|
+
'google-chrome',
|
|
47
|
+
'google-chrome-stable',
|
|
48
|
+
'chromium',
|
|
49
|
+
'chromium-browser',
|
|
50
|
+
'microsoft-edge',
|
|
51
|
+
'msedge',
|
|
52
|
+
'brave-browser',
|
|
53
|
+
].filter((candidate) => typeof candidate === 'string' && candidate.length > 0)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find one usable browser and remember it.
|
|
57
|
+
* @param run - the command runner injected by the Host half.
|
|
58
|
+
* @returns a cached handle, or null when nothing usable is installed.
|
|
59
|
+
*/
|
|
60
|
+
export async function detectRenderer(run) {
|
|
61
|
+
if (detectRenderer.cached !== undefined) return detectRenderer.cached
|
|
62
|
+
for (const candidate of CANDIDATES) {
|
|
63
|
+
const isPath = candidate.includes('/') || candidate.includes('\\')
|
|
64
|
+
const absolute = isPath ? existsSync(candidate) : true
|
|
65
|
+
if (!absolute) continue
|
|
66
|
+
const probe = await run([candidate, '--version'], process.cwd(), { timeoutMs: 8_000 }).catch(() => null)
|
|
67
|
+
if (probe !== null && probe.code === 0) {
|
|
68
|
+
detectRenderer.cached = { command: candidate, version: probe.stdout.trim().split('\n')[0] ?? '' }
|
|
69
|
+
return detectRenderer.cached
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
detectRenderer.cached = null
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render one HTML file to a PNG at the reference viewport.
|
|
78
|
+
* @param args - runner, browser handle, source file, output path, viewport.
|
|
79
|
+
* @returns the output path on success, or null.
|
|
80
|
+
*/
|
|
81
|
+
export async function renderPage({ run, renderer, filePath, outPath, viewport, timeoutMs = 30_000, attempts = 2 }) {
|
|
82
|
+
if (renderer === null || renderer === undefined) return null
|
|
83
|
+
// Cold, concurrent Chrome launches are occasionally flaky: the process exits
|
|
84
|
+
// without writing the screenshot. One quiet retry costs little and removes a
|
|
85
|
+
// whole class of "lane randomly has no score" reports.
|
|
86
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
87
|
+
const rendered = await renderOnce({ run, renderer, filePath, outPath, viewport, timeoutMs })
|
|
88
|
+
if (rendered !== null) return rendered
|
|
89
|
+
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One screenshot attempt: exactly one browser process, one profile, one file. */
|
|
95
|
+
async function renderOnce({ run, renderer, filePath, outPath, viewport, timeoutMs }) {
|
|
96
|
+
const [width, height] = String(viewport ?? '1280x800')
|
|
97
|
+
.split('x')
|
|
98
|
+
.map((part) => Number.parseInt(part, 10))
|
|
99
|
+
const size = `${Number.isFinite(width) ? width : 1280},${Number.isFinite(height) ? height : 800}`
|
|
100
|
+
// Lanes finish at unpredictable moments and can overlap. Without an explicit
|
|
101
|
+
// per-render profile directory, concurrent Chrome launches contend for the
|
|
102
|
+
// default one and one of them exits without writing a screenshot.
|
|
103
|
+
const profileDir = `${outPath}.profile`
|
|
104
|
+
let result
|
|
105
|
+
try {
|
|
106
|
+
result = await run([
|
|
107
|
+
renderer.command,
|
|
108
|
+
'--headless=new',
|
|
109
|
+
'--disable-gpu',
|
|
110
|
+
'--hide-scrollbars',
|
|
111
|
+
'--no-first-run',
|
|
112
|
+
'--no-default-browser-check',
|
|
113
|
+
'--disable-extensions',
|
|
114
|
+
'--disable-background-networking',
|
|
115
|
+
'--disable-dev-shm-usage',
|
|
116
|
+
'--virtual-time-budget=2500',
|
|
117
|
+
`--user-data-dir=${profileDir}`,
|
|
118
|
+
`--window-size=${size}`,
|
|
119
|
+
`--screenshot=${outPath}`,
|
|
120
|
+
pathToFileURL(filePath).href,
|
|
121
|
+
], process.cwd(), { timeoutMs }).catch(() => null)
|
|
122
|
+
} finally {
|
|
123
|
+
await rm(profileDir, { recursive: true, force: true }).catch(() => {})
|
|
124
|
+
}
|
|
125
|
+
if (result === null) return null
|
|
126
|
+
// Chrome exits non-zero on some platforms even after writing the file, and on
|
|
127
|
+
// macOS it can announce "bytes written" slightly before the file is visible,
|
|
128
|
+
// so the file itself decides success — after a short, bounded wait. The file
|
|
129
|
+
// must also decode: a truncated screenshot would otherwise score as garbage.
|
|
130
|
+
for (let wait = 0; wait < 3000; wait += 50) {
|
|
131
|
+
if (existsSync(outPath)) {
|
|
132
|
+
try {
|
|
133
|
+
const decoded = decodePng(await readFile(outPath))
|
|
134
|
+
if (decoded.width > 0 && decoded.height > 0) return outPath
|
|
135
|
+
} catch {
|
|
136
|
+
/* still being written, or a failed capture: keep waiting, then retry */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
140
|
+
}
|
|
141
|
+
await rm(outPath, { force: true }).catch(() => {})
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Test seam: forget the detected browser. */
|
|
146
|
+
export function resetRendererCache() {
|
|
147
|
+
detectRenderer.cached = undefined
|
|
148
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composition of "score one lane": find a browser, screenshot the page,
|
|
3
|
+
* decode both PNGs, compare them.
|
|
4
|
+
*
|
|
5
|
+
* It lives here rather than in index.js so the Host half and the integration
|
|
6
|
+
* test drive exactly the same code path — the test then asserts the real wiring
|
|
7
|
+
* (engine → score → snapshot) instead of a copy of it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { detectRenderer, renderPage } from './render.js'
|
|
14
|
+
import { scoreRender } from './score.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the `scoreLane` dependency for the engine.
|
|
18
|
+
* @param run - command runner `(argv, cwd, options) => Promise<{code, stdout, stderr}>`.
|
|
19
|
+
* @returns an async function `({laneDir, referencePath, outPath, viewport}) => {score, outPath} | null`.
|
|
20
|
+
*/
|
|
21
|
+
export function makeScoreLane(run) {
|
|
22
|
+
return async function scoreLane({ laneDir, referencePath, outPath, viewport }) {
|
|
23
|
+
const browser = await detectRenderer(run)
|
|
24
|
+
if (browser === null) return null
|
|
25
|
+
await mkdir(dirname(outPath), { recursive: true })
|
|
26
|
+
const rendered = await renderPage({
|
|
27
|
+
run,
|
|
28
|
+
renderer: browser,
|
|
29
|
+
filePath: join(laneDir, 'index.html'),
|
|
30
|
+
outPath,
|
|
31
|
+
viewport,
|
|
32
|
+
})
|
|
33
|
+
if (rendered === null) return null
|
|
34
|
+
const [referencePng, renderPng] = await Promise.all([readFile(referencePath), readFile(rendered)])
|
|
35
|
+
const { score } = scoreRender({ referencePng, renderPng })
|
|
36
|
+
return { score, outPath: rendered }
|
|
37
|
+
}
|
|
38
|
+
}
|
package/lib/score.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference-vs-render comparison.
|
|
3
|
+
*
|
|
4
|
+
* The claim dsh-clone makes is deliberately modest: how much of the picture
|
|
5
|
+
* matches, not whether the page is *correct*. Three things make the number
|
|
6
|
+
* useful instead of decorative:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Coarse colour grid.** Both images are averaged into cells, so the score
|
|
9
|
+
* measures layout and palette agreement — what a person judges first.
|
|
10
|
+
* 2. **Shift tolerance.** A rebuild that is a few pixels off is not a
|
|
11
|
+
* different design. The colour comparison searches small cell offsets and
|
|
12
|
+
* keeps the best agreement, so a 4px shift costs almost nothing while a
|
|
13
|
+
* panel on the wrong side still costs a lot.
|
|
14
|
+
* 3. **Structure term.** Local contrast (edge energy) per cell is compared
|
|
15
|
+
* separately and mixed in, so a flat grey block that happens to average to
|
|
16
|
+
* the right colour cannot score as a faithful rebuild.
|
|
17
|
+
*
|
|
18
|
+
* The result is `100` for an identical image and falls off with picture
|
|
19
|
+
* disagreement. It never claims correctness.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { decodePng } from './png.js'
|
|
23
|
+
|
|
24
|
+
/** Grid resolution: 48x32 keeps the comparison cheap and layout-oriented. */
|
|
25
|
+
export const GRID = { columns: 48, rows: 32 }
|
|
26
|
+
|
|
27
|
+
/** Largest cell shift the colour comparison forgives (in cells). */
|
|
28
|
+
export const MAX_SHIFT = 2
|
|
29
|
+
|
|
30
|
+
/** How the final score mixes the two measurements. */
|
|
31
|
+
export const WEIGHTS = { colour: 0.62, structure: 0.38 }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Average each image into a coarse grid of RGB triples.
|
|
35
|
+
* @param image - `{ width, height, data }` with straight RGBA pixels.
|
|
36
|
+
* @returns Float32Array of `columns * rows * 3` values in 0..255.
|
|
37
|
+
*/
|
|
38
|
+
export function gridOf(image, grid = GRID) {
|
|
39
|
+
const { width, height, data } = image
|
|
40
|
+
const columns = grid.columns
|
|
41
|
+
const rows = grid.rows
|
|
42
|
+
const sums = new Float64Array(columns * rows * 3)
|
|
43
|
+
const counts = new Float64Array(columns * rows)
|
|
44
|
+
|
|
45
|
+
for (let y = 0; y < height; y += 1) {
|
|
46
|
+
const row = Math.min(rows - 1, Math.floor((y * rows) / height))
|
|
47
|
+
for (let x = 0; x < width; x += 1) {
|
|
48
|
+
const cell = row * columns + Math.min(columns - 1, Math.floor((x * columns) / width))
|
|
49
|
+
const index = (y * width + x) * 4
|
|
50
|
+
const alpha = data[index + 3] / 255
|
|
51
|
+
// Transparent pixels are composited over white, which is what a browser
|
|
52
|
+
// screenshot of an unstyled page looks like.
|
|
53
|
+
const red = data[index] * alpha + 255 * (1 - alpha)
|
|
54
|
+
const green = data[index + 1] * alpha + 255 * (1 - alpha)
|
|
55
|
+
const blue = data[index + 2] * alpha + 255 * (1 - alpha)
|
|
56
|
+
sums[cell * 3] += red
|
|
57
|
+
sums[cell * 3 + 1] += green
|
|
58
|
+
sums[cell * 3 + 2] += blue
|
|
59
|
+
counts[cell] += 1
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const out = new Float32Array(columns * rows * 3)
|
|
64
|
+
for (let cell = 0; cell < counts.length; cell += 1) {
|
|
65
|
+
const count = counts[cell]
|
|
66
|
+
if (count === 0) continue
|
|
67
|
+
out[cell * 3] = sums[cell * 3] / count
|
|
68
|
+
out[cell * 3 + 1] = sums[cell * 3 + 1] / count
|
|
69
|
+
out[cell * 3 + 2] = sums[cell * 3 + 2] / count
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Per-cell luminance edge energy.
|
|
76
|
+
*
|
|
77
|
+
* A cell's value is the mean absolute luminance gradient inside it, scaled so a
|
|
78
|
+
* strong edge is near 255. This is what distinguishes "the same average colour"
|
|
79
|
+
* from "the same picture": a solid block and a striped block can average the
|
|
80
|
+
* same grey but never the same edge energy.
|
|
81
|
+
* @param image - decoded image.
|
|
82
|
+
* @returns Float32Array of `columns * rows` values in 0..255.
|
|
83
|
+
*/
|
|
84
|
+
export function edgeGridOf(image, grid = GRID) {
|
|
85
|
+
const { width, height, data } = image
|
|
86
|
+
const columns = grid.columns
|
|
87
|
+
const rows = grid.rows
|
|
88
|
+
const sums = new Float64Array(columns * rows)
|
|
89
|
+
const counts = new Float64Array(columns * rows)
|
|
90
|
+
|
|
91
|
+
const luminance = (x, y) => {
|
|
92
|
+
const index = (y * width + x) * 4
|
|
93
|
+
const alpha = data[index + 3] / 255
|
|
94
|
+
const red = data[index] * alpha + 255 * (1 - alpha)
|
|
95
|
+
const green = data[index + 1] * alpha + 255 * (1 - alpha)
|
|
96
|
+
const blue = data[index + 2] * alpha + 255 * (1 - alpha)
|
|
97
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (let y = 0; y < height; y += 1) {
|
|
101
|
+
const row = Math.min(rows - 1, Math.floor((y * rows) / height))
|
|
102
|
+
for (let x = 0; x < width; x += 1) {
|
|
103
|
+
const cell = row * columns + Math.min(columns - 1, Math.floor((x * columns) / width))
|
|
104
|
+
const right = x + 1 < width ? luminance(x + 1, y) : luminance(x, y)
|
|
105
|
+
const down = y + 1 < height ? luminance(x, y + 1) : luminance(x, y)
|
|
106
|
+
sums[cell] += Math.abs(right - luminance(x, y)) + Math.abs(down - luminance(x, y))
|
|
107
|
+
counts[cell] += 1
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const out = new Float32Array(columns * rows)
|
|
112
|
+
for (let cell = 0; cell < counts.length; cell += 1) {
|
|
113
|
+
if (counts[cell] === 0) continue
|
|
114
|
+
// A 40-unit luminance step is already a hard edge; clamp so a few extreme
|
|
115
|
+
// cells cannot dominate the mean.
|
|
116
|
+
out[cell] = Math.min(255, (sums[cell] / counts[cell]) * 8)
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Turn a list of per-cell absolute differences into a similarity.
|
|
123
|
+
*
|
|
124
|
+
* A plain mean hides local failure: a page that reproduces 90% of a screen and
|
|
125
|
+
* leaves one panel blank still averages well. The mix below keeps the mean (how
|
|
126
|
+
* much agrees) and adds the 90th percentile (how bad the worst regions are), so
|
|
127
|
+
* a wrong panel costs real points while a 4px shift costs almost nothing.
|
|
128
|
+
* @param differences - per-cell absolute differences in 0..255.
|
|
129
|
+
* @returns 0..100 similarity.
|
|
130
|
+
*/
|
|
131
|
+
function similarityFrom(differences) {
|
|
132
|
+
if (differences.length === 0) return 0
|
|
133
|
+
const sorted = Float64Array.from(differences).sort()
|
|
134
|
+
const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length
|
|
135
|
+
const p90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))]
|
|
136
|
+
const blended = mean * 0.62 + p90 * 0.38
|
|
137
|
+
return Math.max(0, Math.min(100, 100 - (blended / 255) * 100))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Soften an edge grid with a 3x3 box pass.
|
|
142
|
+
*
|
|
143
|
+
* Raw edge energy is unforgiving: a 2px text shift rewrites almost every edge
|
|
144
|
+
* value. Blurring twice keeps the shape of "where the detail is" while dropping
|
|
145
|
+
* the exact pixel alignment, which is the property a person judges.
|
|
146
|
+
* @param grid - edge grid of `columns * rows`.
|
|
147
|
+
* @returns a new, softened grid.
|
|
148
|
+
*/
|
|
149
|
+
export function blurEdgeGrid(grid, gridSize = GRID) {
|
|
150
|
+
const columns = gridSize.columns
|
|
151
|
+
const rows = gridSize.rows
|
|
152
|
+
const out = new Float32Array(grid.length)
|
|
153
|
+
for (let y = 0; y < rows; y += 1) {
|
|
154
|
+
for (let x = 0; x < columns; x += 1) {
|
|
155
|
+
let sum = 0
|
|
156
|
+
let count = 0
|
|
157
|
+
for (let dy = -1; dy <= 1; dy += 1) {
|
|
158
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
159
|
+
const yy = y + dy
|
|
160
|
+
const xx = x + dx
|
|
161
|
+
if (yy < 0 || yy >= rows || xx < 0 || xx >= columns) continue
|
|
162
|
+
sum += grid[yy * columns + xx]
|
|
163
|
+
count += 1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
out[y * columns + x] = sum / count
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Structural agreement, measured against the reference's own edge energy.
|
|
174
|
+
*
|
|
175
|
+
* Absolute differences are the wrong scale here: edge values are small, so a
|
|
176
|
+
* page that loses every detail would still look "close". Dividing by how much
|
|
177
|
+
* edge energy the reference actually has makes a missing structure expensive and
|
|
178
|
+
* a faithful one cheap.
|
|
179
|
+
* @param reference - softened reference edge grid.
|
|
180
|
+
* @param render - softened rendered edge grid.
|
|
181
|
+
* @returns 0..100 similarity.
|
|
182
|
+
*/
|
|
183
|
+
function structureSimilarity(reference, render) {
|
|
184
|
+
if (reference.length !== render.length) throw new Error('score: grid sizes differ')
|
|
185
|
+
let difference = 0
|
|
186
|
+
let energy = 0
|
|
187
|
+
for (let index = 0; index < reference.length; index += 1) {
|
|
188
|
+
difference += Math.abs(reference[index] - render[index])
|
|
189
|
+
energy += reference[index]
|
|
190
|
+
}
|
|
191
|
+
// A reference with no edges at all is satisfied by anything without edges.
|
|
192
|
+
if (energy < 1) return 100
|
|
193
|
+
return Math.max(0, Math.min(100, 100 * (1 - difference / energy)))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Colour similarity that forgives a small uniform shift.
|
|
198
|
+
* @param reference - reference colour grid.
|
|
199
|
+
* @param render - rendered colour grid.
|
|
200
|
+
* @param grid - grid dimensions the two arrays were built with.
|
|
201
|
+
* @param maxShift - largest cell offset to try in each axis.
|
|
202
|
+
* @returns `{ similarity, shift }` with the best offset found.
|
|
203
|
+
*/
|
|
204
|
+
export function colourSimilarity(reference, render, grid = GRID, maxShift = MAX_SHIFT) {
|
|
205
|
+
let best = { similarity: -1, shift: { x: 0, y: 0 } }
|
|
206
|
+
for (let dy = -maxShift; dy <= maxShift; dy += 1) {
|
|
207
|
+
for (let dx = -maxShift; dx <= maxShift; dx += 1) {
|
|
208
|
+
// Shift the render against the reference and compare only the overlap.
|
|
209
|
+
const x0 = Math.max(0, dx)
|
|
210
|
+
const y0 = Math.max(0, dy)
|
|
211
|
+
const x1 = Math.min(grid.columns, grid.columns + dx)
|
|
212
|
+
const y1 = Math.min(grid.rows, grid.rows + dy)
|
|
213
|
+
if (x1 - x0 < grid.columns - maxShift || y1 - y0 < grid.rows - maxShift) continue
|
|
214
|
+
const differences = []
|
|
215
|
+
for (let y = y0; y < y1; y += 1) {
|
|
216
|
+
for (let x = x0; x < x1; x += 1) {
|
|
217
|
+
const renderCell = (y * grid.columns + x) * 3
|
|
218
|
+
const referenceCell = ((y - dy) * grid.columns + (x - dx)) * 3
|
|
219
|
+
differences.push(
|
|
220
|
+
(Math.abs(reference[referenceCell] - render[renderCell])
|
|
221
|
+
+ Math.abs(reference[referenceCell + 1] - render[renderCell + 1])
|
|
222
|
+
+ Math.abs(reference[referenceCell + 2] - render[renderCell + 2])) / 3,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (differences.length === 0) continue
|
|
227
|
+
const similarity = similarityFrom(differences)
|
|
228
|
+
if (similarity > best.similarity) best = { similarity, shift: { x: dx, y: dy } }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (best.similarity < 0) {
|
|
232
|
+
// Degenerate grid (smaller than the shift window): compare without shifting.
|
|
233
|
+
return { similarity: similarityOf(reference, render), shift: { x: 0, y: 0 } }
|
|
234
|
+
}
|
|
235
|
+
return best
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Mix colour agreement and structural agreement into one number.
|
|
240
|
+
* @param colour - 0..100 colour similarity.
|
|
241
|
+
* @param structure - 0..100 structural similarity.
|
|
242
|
+
* @returns 0..100, one decimal.
|
|
243
|
+
*/
|
|
244
|
+
export function combineScore(colour, structure) {
|
|
245
|
+
const mixed = colour * WEIGHTS.colour + structure * WEIGHTS.structure
|
|
246
|
+
return Math.round(Math.max(0, Math.min(100, mixed)) * 10) / 10
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Score one rendered lane against the reference screenshot.
|
|
251
|
+
* @param args - raw PNG bytes of both images, plus an optional grid.
|
|
252
|
+
* @returns `{ score, colour, structure, shift }` — score rounded to one decimal.
|
|
253
|
+
*/
|
|
254
|
+
export function scoreRender({ referencePng, renderPng, grid = GRID }) {
|
|
255
|
+
const reference = decodePng(referencePng)
|
|
256
|
+
const render = decodePng(renderPng)
|
|
257
|
+
const colour = colourSimilarity(gridOf(reference, grid), gridOf(render, grid), grid)
|
|
258
|
+
const softEdges = (image) => blurEdgeGrid(blurEdgeGrid(edgeGridOf(image, grid), grid), grid)
|
|
259
|
+
const structure = structureSimilarity(softEdges(reference), softEdges(render))
|
|
260
|
+
return {
|
|
261
|
+
score: combineScore(colour.similarity, structure),
|
|
262
|
+
colour: Math.round(colour.similarity * 10) / 10,
|
|
263
|
+
structure: Math.round(structure * 10) / 10,
|
|
264
|
+
shift: colour.shift,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* One-line verdict for the panel and the tool output.
|
|
270
|
+
*
|
|
271
|
+
* Bands are calibrated against the measured runs in this repository: a faithful
|
|
272
|
+
* rebuild of a dense screen lands at 95-99, a good-but-flawed one at 87-92, and
|
|
273
|
+
* two unrelated dark interfaces at about 80.
|
|
274
|
+
*/
|
|
275
|
+
export function scoreLabel(score) {
|
|
276
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) return 'not scored'
|
|
277
|
+
if (score >= 95) return `${score}% match — near-identical`
|
|
278
|
+
if (score >= 90) return `${score}% match — close`
|
|
279
|
+
if (score >= 80) return `${score}% match — recognisable`
|
|
280
|
+
if (score >= 60) return `${score}% match — same idea, different layout`
|
|
281
|
+
return `${score}% match — a different page`
|
|
282
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small dependency-free helpers shared by the Host half and the tests.
|
|
3
|
+
* Nothing here touches the filesystem or the Harness: keep it pure so the
|
|
4
|
+
* smoke test can exercise it in plain Node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Clamp a candidate lane count into the supported range. */
|
|
8
|
+
export function clampLanes(value, { min = 1, max = 4, fallback = 3 } = {}) {
|
|
9
|
+
const numeric = typeof value === 'number' ? value : Number(value)
|
|
10
|
+
if (!Number.isFinite(numeric)) return fallback
|
|
11
|
+
const rounded = Math.floor(numeric)
|
|
12
|
+
if (rounded < min) return min
|
|
13
|
+
if (rounded > max) return max
|
|
14
|
+
return rounded
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalize an unknown thrown value into one readable line. */
|
|
18
|
+
export function reasonOf(error) {
|
|
19
|
+
if (error === null || error === undefined) return 'unknown error'
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
21
|
+
return message.replace(/\s+/gu, ' ').trim().slice(0, 400) || 'unknown error'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Cut a string to a display budget without splitting a surrogate pair. */
|
|
25
|
+
export function truncate(text, limit) {
|
|
26
|
+
const value = String(text ?? '')
|
|
27
|
+
if (value.length <= limit) return value
|
|
28
|
+
const cut = value.slice(0, Math.max(0, limit - 1))
|
|
29
|
+
return `${Array.from(cut).slice(0, -1).join('')}…`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable, filesystem-safe slug for the picked clone's destination folder. */
|
|
33
|
+
export function slugify(text, fallback = 'clone') {
|
|
34
|
+
const value = String(text ?? '')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9._-]+/gu, '-')
|
|
37
|
+
.replace(/-+/gu, '-')
|
|
38
|
+
.replace(/^[-_.]+|[-_.]+$/gu, '')
|
|
39
|
+
return value.length === 0 ? fallback : value.slice(0, 40)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Human duration for the panel: 4.2s, 1m 07s, 12m. */
|
|
43
|
+
export function formatDuration(ms) {
|
|
44
|
+
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
45
|
+
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
46
|
+
const seconds = ms / 1000
|
|
47
|
+
if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`
|
|
48
|
+
const minutes = Math.floor(seconds / 60)
|
|
49
|
+
const rest = Math.round(seconds - minutes * 60)
|
|
50
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${String(rest).padStart(2, '0')}s`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Total token count for one lane's accumulated usage. */
|
|
54
|
+
export function totalTokens(tokens) {
|
|
55
|
+
if (tokens === null || tokens === undefined) return 0
|
|
56
|
+
const input = Number(tokens.input) || 0
|
|
57
|
+
const output = Number(tokens.output) || 0
|
|
58
|
+
const cache = Number(tokens.cacheRead) || 0
|
|
59
|
+
return input + output + cache
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Lowercase file extension including the dot, or '' when there is none. */
|
|
63
|
+
export function extensionOf(filePath) {
|
|
64
|
+
const name = String(filePath ?? '')
|
|
65
|
+
const dot = name.lastIndexOf('.')
|
|
66
|
+
if (dot <= 0) return ''
|
|
67
|
+
return name.slice(dot).toLowerCase()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a path ends in a readable raster image extension. */
|
|
71
|
+
export function isImagePath(filePath) {
|
|
72
|
+
return ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(extensionOf(filePath))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Media type for the raster formats dsh-clone accepts as a reference. */
|
|
76
|
+
export function mediaTypeOf(filePath) {
|
|
77
|
+
switch (extensionOf(filePath)) {
|
|
78
|
+
case '.png': return 'image/png'
|
|
79
|
+
case '.jpg':
|
|
80
|
+
case '.jpeg': return 'image/jpeg'
|
|
81
|
+
case '.webp': return 'image/webp'
|
|
82
|
+
case '.gif': return 'image/gif'
|
|
83
|
+
default: return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
package/locale/en.json
ADDED
package/locale/ru.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-clone",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Screenshot in, working UI out: several agents rebuild the same reference interface in parallel, and you pick the live result you like best.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Egor Fedorov",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/egorfedorov/dsh-clone.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/egorfedorov/dsh-clone",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/egorfedorov/dsh-clone/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"screenshot-to-code",
|
|
21
|
+
"ui-clone",
|
|
22
|
+
"design-to-code",
|
|
23
|
+
"subagents",
|
|
24
|
+
"worktree"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20",
|
|
28
|
+
"dsh": ">=0.2.0-rc.2 || >=0.2.1-rc.2 || >=0.2.2-rc.1"
|
|
29
|
+
},
|
|
30
|
+
"main": "index.js",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./client": "./client.js",
|
|
34
|
+
"./package.json": "./package.json",
|
|
35
|
+
"./locale/*.json": "./locale/*.json",
|
|
36
|
+
"./icon": "./icon.svg"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"index.js",
|
|
40
|
+
"client.js",
|
|
41
|
+
"lib/*.js",
|
|
42
|
+
"locale/*.json",
|
|
43
|
+
"icon.svg",
|
|
44
|
+
"cordis.patch.yml",
|
|
45
|
+
"README.md",
|
|
46
|
+
"ACCEPTABLE-USE.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"index.json",
|
|
49
|
+
"screenshots.json",
|
|
50
|
+
"docs/assets/icon-180.png",
|
|
51
|
+
"CHANGELOG.md"
|
|
52
|
+
],
|
|
53
|
+
"dsh": {
|
|
54
|
+
"bundle": {
|
|
55
|
+
"patch": "./cordis.patch.yml"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"platform": "web",
|
|
59
|
+
"immediately": true,
|
|
60
|
+
"inject": [
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"test": "node test/smoke.mjs",
|
|
67
|
+
"test:git": "node test/git-lane.mjs",
|
|
68
|
+
"test:all": "node test/smoke.mjs && node test/git-lane.mjs && node test/render-score.mjs",
|
|
69
|
+
"check": "node --check index.js && node --check client.js && for f in lib/*.js; do node --check \"$f\" || exit 1; done",
|
|
70
|
+
"test:score": "node test/render-score.mjs"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
}
|
|
75
|
+
}
|