Files
agent-desirecore/web/solver-report/scripts/create-public-screenshots.mjs
2026-08-30 11:02:51 -04:00

144 lines
5.7 KiB
JavaScript

import { createRequire } from 'node:module'
import { cpSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { basename, dirname, join, relative, resolve } from 'node:path'
import { canonical, publicTextViolations, sha256 } from './public-release-policy.mjs'
const inputRoot = resolve(process.env.SOLVER_REPORT_INPUT_ROOT ?? process.cwd())
const appRoot = process.env.DESIRECORE_APP_ROOT
const ocrFile = process.env.SOLVER_REPORT_SCREENSHOT_OCR_FILE
if (!appRoot) throw new Error('DESIRECORE_APP_ROOT is required')
if (!ocrFile) throw new Error('SOLVER_REPORT_SCREENSHOT_OCR_FILE is required')
const requireFromApp = createRequire(join(resolve(appRoot), 'package.json'))
const sharp = requireFromApp('sharp')
const planFile = join(inputRoot, 'evidence', 'screenshots', 'screenshot-plan.json')
const outputPlanFile = join(inputRoot, 'evidence', 'screenshots', 'public-screenshot-plan.json')
const outputRoot = join(inputRoot, 'evidence', 'screenshots', 'public-redacted')
const sourcePlan = JSON.parse(readFileSync(planFile, 'utf8'))
const ocr = JSON.parse(readFileSync(ocrFile, 'utf8'))
const sourceScreenshots = sourcePlan.scenarios.flatMap((scenario) =>
scenario.screenshots.map((screenshot) => ({ scenarioId: scenario.scenarioId, ...screenshot }))
)
if (ocr.schemaVersion !== 'solver.screenshot-privacy-ocr/v1' || ocr.entries.length !== sourceScreenshots.length) {
throw new Error('OCR result does not cover the source screenshot plan')
}
const clamp = (value, minimum, maximum) => Math.max(minimum, Math.min(maximum, value))
const overlaps = (left, right) =>
left.left <= right.left + right.width &&
right.left <= left.left + left.width &&
left.top <= right.top + right.height &&
right.top <= left.top + left.height
const mergeRegions = (regions) => {
const merged = []
for (const region of regions) {
const existing = merged.find((candidate) => overlaps(candidate, region))
if (!existing) {
merged.push({ ...region })
continue
}
const right = Math.max(existing.left + existing.width, region.left + region.width)
const bottom = Math.max(existing.top + existing.height, region.top + region.height)
existing.left = Math.min(existing.left, region.left)
existing.top = Math.min(existing.top, region.top)
existing.width = right - existing.left
existing.height = bottom - existing.top
}
return merged
}
let redactedScreenshots = 0
let redactionRegions = 0
const publicByFile = new Map()
for (const [index, source] of sourceScreenshots.entries()) {
const ocrEntry = ocr.entries[index]
const sourceFile = join(inputRoot, source.file)
if (
ocrEntry?.scenarioId !== source.scenarioId ||
ocrEntry?.file !== source.file ||
sha256(readFileSync(sourceFile)) !== source.sha256
) {
throw new Error(`${source.file}: OCR source identity differs from screenshot plan`)
}
const metadata = await sharp(sourceFile).metadata()
if (metadata.width !== source.width || metadata.height !== source.height) {
throw new Error(`${source.file}: source dimensions differ from screenshot plan`)
}
const proposedRegions = ocrEntry.lines
.filter((line) => publicTextViolations(line.text).length > 0)
.map((line) => {
const [x, y, width, height] = line.boundingBox
const left = clamp(Math.floor(x * source.width) - 12, 0, source.width - 1)
const top = clamp(Math.floor((1 - y - height) * source.height) - 8, 0, source.height - 1)
const right = clamp(Math.ceil((x + width) * source.width) + 12, left + 1, source.width)
const bottom = clamp(Math.ceil((1 - y) * source.height) + Math.ceil(height * source.height * 1.2) + 8, top + 1, source.height)
return { left, top, width: right - left, height: bottom - top, reason: 'private-path' }
})
const regions = mergeRegions(proposedRegions)
const targetRelative = `evidence/screenshots/public-redacted/${source.scenarioId}/${basename(source.file)}`
const targetFile = join(inputRoot, targetRelative)
if (relative(outputRoot, targetFile).startsWith('..')) throw new Error(`${targetRelative}: unsafe public screenshot target`)
mkdirSync(dirname(targetFile), { recursive: true })
if (regions.length === 0) cpSync(sourceFile, targetFile)
else {
await sharp(sourceFile)
.composite(
regions.map((region) => ({
input: {
create: {
width: region.width,
height: region.height,
channels: 4,
background: { r: 32, g: 34, b: 39, alpha: 1 },
},
},
left: region.left,
top: region.top,
}))
)
.png()
.toFile(targetFile)
redactedScreenshots += 1
redactionRegions += regions.length
}
publicByFile.set(source.file, {
file: targetRelative,
sha256: sha256(readFileSync(targetFile)),
width: source.width,
height: source.height,
sourceSha256: source.sha256,
redactions: regions,
})
}
const publicPlan = {
schemaVersion: 'solver.public-screenshot-plan/v1',
generatedAt: new Date().toISOString(),
sourcePlanSha256: sha256(canonical(sourcePlan)),
derivation: {
method: 'Apple Vision exact-line private-path masking',
redactedScreenshots,
redactionRegions,
},
scenarios: sourcePlan.scenarios.map((scenario) => ({
...scenario,
screenshots: scenario.screenshots.map((screenshot) => publicByFile.get(screenshot.file)),
})),
}
writeFileSync(outputPlanFile, `${JSON.stringify(publicPlan, null, 2)}\n`)
console.log(
JSON.stringify(
{
outputPlanFile,
screenshots: sourceScreenshots.length,
redactedScreenshots,
redactionRegions,
bytes: sourceScreenshots.reduce(
(total, screenshot) => total + statSync(join(inputRoot, publicByFile.get(screenshot.file).file)).size,
0
),
},
null,
2
)
)