接口开始5%
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import path from 'node:path'
|
||||
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveInteger = (value, label) => {
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireIntegerInRange = (value, minimum, maximum, label) => {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${label} must be an integer from ${minimum} to ${maximum}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requireExact = (value, expected, label) => {
|
||||
if (value !== expected) throw new Error(`${label} must be ${expected}`)
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveInsideWorkspace = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const validatePixels = (value, label) => {
|
||||
const pixels = requireObject(value, label)
|
||||
assertOnlyFields(pixels, new Set(['width', 'height']), label)
|
||||
requirePositiveInteger(pixels.width, `${label}.width`)
|
||||
requirePositiveInteger(pixels.height, `${label}.height`)
|
||||
}
|
||||
|
||||
const validateAlphaAndEdge = (asset, id) => {
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
assertOnlyFields(alpha, new Set(['required', 'transparentOuterPadding', 'cornerMaxAlpha']), `${id}.alpha`)
|
||||
requireExact(alpha.required, true, `${id}.alpha.required`)
|
||||
requireIntegerInRange(alpha.transparentOuterPadding, 0, 4096, `${id}.alpha.transparentOuterPadding`)
|
||||
requireIntegerInRange(alpha.cornerMaxAlpha, 0, 255, `${id}.alpha.cornerMaxAlpha`)
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
assertOnlyFields(
|
||||
edge,
|
||||
new Set(['forbidChromaResidue', 'forbidLightFringe', 'premultipliedAlphaCheck']),
|
||||
`${id}.edge`,
|
||||
)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
}
|
||||
|
||||
const validateProcessing = (processing, id) => {
|
||||
requireObject(processing, `${id}.processing`)
|
||||
const mode = requireString(processing.mode, `${id}.processing.mode`)
|
||||
|
||||
if (mode === 'chroma-stretch') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set(['mode', 'capWidth', 'keyColor', 'keyTolerance', 'paletteColors', 'indexedPng']),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.capWidth, `${id}.processing.capWidth`)
|
||||
if (!/^#[A-Fa-f0-9]{6}$/.test(processing.keyColor)) {
|
||||
throw new Error(`${id}.processing.keyColor must be a six-digit RGB color`)
|
||||
}
|
||||
requireIntegerInRange(processing.keyTolerance, 0, 441, `${id}.processing.keyTolerance`)
|
||||
if (processing.paletteColors !== undefined) {
|
||||
requireIntegerInRange(processing.paletteColors, 2, 256, `${id}.processing.paletteColors`)
|
||||
}
|
||||
if (processing.indexedPng !== undefined) {
|
||||
requireBoolean(processing.indexedPng, `${id}.processing.indexedPng`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (mode === 'opaque-resize') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'opaque-cover-crop') {
|
||||
assertOnlyFields(processing, new Set(['mode', 'resample', 'anchor', 'outputMode']), `${id}.processing`)
|
||||
requireExact(processing.resample, 'lanczos', `${id}.processing.resample`)
|
||||
requireExact(processing.anchor, 'center', `${id}.processing.anchor`)
|
||||
requireExact(processing.outputMode, 'RGB', `${id}.processing.outputMode`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (mode === 'warm-gold-frame-extract') {
|
||||
assertOnlyFields(
|
||||
processing,
|
||||
new Set([
|
||||
'mode',
|
||||
'borderBand',
|
||||
'redGreenMin',
|
||||
'greenBlueMin',
|
||||
'redBlueMin',
|
||||
'redMaxExclusive',
|
||||
'blueMaxExclusive',
|
||||
'alphaOffset',
|
||||
'alphaScale',
|
||||
'outputMode',
|
||||
]),
|
||||
`${id}.processing`,
|
||||
)
|
||||
requirePositiveInteger(processing.borderBand, `${id}.processing.borderBand`)
|
||||
for (const field of ['redGreenMin', 'greenBlueMin', 'redBlueMin', 'alphaOffset']) {
|
||||
requireIntegerInRange(processing[field], 0, 255, `${id}.processing.${field}`)
|
||||
}
|
||||
for (const field of ['redMaxExclusive', 'blueMaxExclusive']) {
|
||||
requireIntegerInRange(processing[field], 1, 256, `${id}.processing.${field}`)
|
||||
}
|
||||
requirePositiveInteger(processing.alphaScale, `${id}.processing.alphaScale`)
|
||||
requireExact(processing.outputMode, 'RGBA', `${id}.processing.outputMode`)
|
||||
return true
|
||||
}
|
||||
|
||||
throw new Error(`${id} has unsupported processing.mode: ${mode}`)
|
||||
}
|
||||
|
||||
export const validateAssetBuildManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'family', 'assets']), 'manifest')
|
||||
if (manifest.schemaVersion !== 3) throw new Error('schemaVersion must be 3')
|
||||
if (manifest.kind !== 'asset-build-manifest') throw new Error('kind must be asset-build-manifest')
|
||||
requireString(manifest.family, 'family')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
||||
throw new Error('assets must be a non-empty array')
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
const outputs = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const processing = requireObject(asset.processing, 'asset.processing')
|
||||
const mode = requireString(processing.mode, 'asset.processing.mode')
|
||||
const transparent = ['chroma-stretch', 'warm-gold-frame-extract'].includes(mode)
|
||||
const fields = new Set(['id', 'source', 'output', 'sourcePixels', 'outputPixels', 'quality', 'processing'])
|
||||
if (transparent) {
|
||||
fields.add('alpha')
|
||||
fields.add('edge')
|
||||
}
|
||||
assertOnlyFields(asset, fields, 'asset')
|
||||
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
resolveInsideWorkspace(workspace, asset.source, `${id}.source`)
|
||||
resolveInsideWorkspace(workspace, asset.output, `${id}.output`)
|
||||
if (outputs.has(asset.output)) throw new Error(`duplicate output: ${asset.output}`)
|
||||
outputs.add(asset.output)
|
||||
|
||||
validatePixels(asset.sourcePixels, `${id}.sourcePixels`)
|
||||
validatePixels(asset.outputPixels, `${id}.outputPixels`)
|
||||
|
||||
const processingNeedsAlpha = validateProcessing(processing, id)
|
||||
if (processingNeedsAlpha) validateAlphaAndEdge(asset, id)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
assertOnlyFields(quality, new Set(['maxBytes', 'colorSpace']), `${id}.quality`)
|
||||
requirePositiveInteger(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireExact(quality.colorSpace, 'sRGB', `${id}.quality.colorSpace`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -15,9 +15,14 @@ def _outer_ring_pixels(image: Image.Image, thickness: int):
|
||||
yield image.getpixel((x, y))
|
||||
|
||||
|
||||
def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Analyze one PNG against its manifest v2 specification."""
|
||||
path = Path(path)
|
||||
def analyze_asset(path: Path, spec: dict[str, Any], workspace: Path) -> dict[str, Any]:
|
||||
"""按 schema v3 物理规格分析单张 PNG,并只输出工作区相对路径。"""
|
||||
workspace = Path(workspace).resolve()
|
||||
path = Path(path).resolve()
|
||||
try:
|
||||
report_path = path.relative_to(workspace).as_posix()
|
||||
except ValueError as error:
|
||||
raise ValueError(f"asset path escapes workspace: {path}") from error
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
metrics: dict[str, int] = {}
|
||||
@@ -40,14 +45,34 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
errors.append(f"alpha-required asset has no alpha channel or transparency table, got {mode}")
|
||||
|
||||
padding = int(alpha.get("transparentOuterPadding", 0))
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 0))
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
max_alpha = int(alpha.get("cornerMaxAlpha", 255))
|
||||
corners = (
|
||||
image.getpixel((0, 0))[3],
|
||||
image.getpixel((width - 1, 0))[3],
|
||||
image.getpixel((0, height - 1))[3],
|
||||
image.getpixel((width - 1, height - 1))[3],
|
||||
)
|
||||
corner_violations = sum(1 for value in corners if value > max_alpha)
|
||||
metrics["cornerAlphaViolations"] = corner_violations
|
||||
if corner_violations:
|
||||
errors.append(f"corners contain {corner_violations} pixels above alpha {max_alpha}")
|
||||
|
||||
# 不透明长背景没有边缘/透明度扫描需求;避免无意义地把每张 1440×3600 图
|
||||
# 展开成数百万个 Python 元组。透明资产仍完整执行原有像素级质量合同。
|
||||
opaque_padding = 0
|
||||
if padding > 0:
|
||||
opaque_padding = sum(1 for pixel in _outer_ring_pixels(image, padding) if pixel[3] > max_alpha)
|
||||
metrics["outerPaddingViolations"] = opaque_padding
|
||||
if opaque_padding:
|
||||
errors.append(f"outer padding contains {opaque_padding} pixels above alpha {max_alpha}")
|
||||
|
||||
edge = spec.get("edge", {})
|
||||
pixels = list(image.get_flattened_data())
|
||||
needs_edge_pixels = any(edge.get(field) for field in (
|
||||
"forbidChromaResidue",
|
||||
"forbidLightFringe",
|
||||
"premultipliedAlphaCheck",
|
||||
))
|
||||
pixels = list(image.get_flattened_data()) if needs_edge_pixels else []
|
||||
|
||||
chroma_residue = sum(
|
||||
1
|
||||
@@ -83,11 +108,15 @@ def analyze_asset(path: Path, spec: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
expected_color_space = spec.get("quality", {}).get("colorSpace")
|
||||
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
|
||||
warnings.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
errors.append("PNG does not declare an sRGB chunk or ICC profile")
|
||||
|
||||
expected_mode = spec.get("processing", {}).get("outputMode")
|
||||
if expected_mode and mode != expected_mode:
|
||||
errors.append(f"output mode expected {expected_mode}, got {mode}")
|
||||
|
||||
return {
|
||||
"id": spec.get("id", path.stem),
|
||||
"path": str(path),
|
||||
"path": report_path,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"mode": mode,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
function run(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,159 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'a01.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
const generatedDirectory = path.join(workspace, 'design-pipeline', 'generated', 'a01')
|
||||
|
||||
const resolveWorkspacePath = (relativePath) => {
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`路径越出工作区:${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
const writeVersionedFile = async (outputPath, buffer) => {
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
try {
|
||||
const current = await readFile(outputPath)
|
||||
if (sha256(current) !== sha256(buffer)) {
|
||||
throw new Error(`拒绝覆盖内容不同的版本化输出:${path.relative(workspace, outputPath)}`)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
const runtimeAssets = new Map()
|
||||
const reportAssets = []
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
const sourcePath = resolveWorkspacePath(asset.source)
|
||||
const outputPath = resolveWorkspacePath(asset.output)
|
||||
await stat(sourcePath)
|
||||
const pipeline = sharp(sourcePath).resize(asset.width, asset.height, { fit: 'fill' })
|
||||
if (!asset.alpha) pipeline.flatten({ background: '#f4eadc' })
|
||||
const buffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false }).toBuffer()
|
||||
await writeVersionedFile(outputPath, buffer)
|
||||
runtimeAssets.set(asset.id, buffer)
|
||||
const metadata = await sharp(buffer).metadata()
|
||||
reportAssets.push({
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
bytes: buffer.length,
|
||||
sha256: sha256(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
const loadShared = async (key) => readFile(resolveWorkspacePath(manifest.shared[key]))
|
||||
const shared = {
|
||||
brandSeal: await loadShared('brandSeal'),
|
||||
lock: await loadShared('lock'),
|
||||
wechat: await loadShared('wechat'),
|
||||
agreementUnchecked: await loadShared('agreementUnchecked')
|
||||
}
|
||||
|
||||
const svgLayer = (mode) => Buffer.from(`
|
||||
<svg width="412" height="915" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.k { font-family: KaiTi, STKaiti, serif; }
|
||||
.red { fill: #9f1717; }
|
||||
.gold { fill: #a97936; }
|
||||
.body { fill: #51443b; }
|
||||
.muted { fill: #9a8e84; }
|
||||
</style>
|
||||
<text x="206" y="321" text-anchor="middle" class="k red" font-size="36">登录家谱</text>
|
||||
<text x="130" y="389" text-anchor="middle" class="k ${mode === 'password' ? 'red' : 'gold'}" font-size="20">密码登录</text>
|
||||
<text x="282" y="389" text-anchor="middle" class="k ${mode === 'sms' ? 'red' : 'gold'}" font-size="20">验证码登录</text>
|
||||
<line x1="61" y1="404" x2="351" y2="404" stroke="#d1a965" stroke-width="1"/>
|
||||
<line x1="${mode === 'password' ? 99 : 251}" y1="401" x2="${mode === 'password' ? 161 : 313}" y2="401" stroke="#bd151b" stroke-width="4"/>
|
||||
<text x="112" y="449" class="k muted" font-size="18">手机号</text>
|
||||
<line x1="61" y1="481" x2="351" y2="481" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="112" y="559" class="k muted" font-size="18">${mode === 'password' ? '密码' : '验证码'}</text>
|
||||
${mode === 'password'
|
||||
? '<text x="349" y="590" text-anchor="end" class="k red" font-size="15">忘记密码</text>'
|
||||
: '<text x="349" y="559" text-anchor="end" class="k red" font-size="15">获取验证码</text>'}
|
||||
<line x1="61" y1="576" x2="351" y2="576" stroke="#d1a965" stroke-width="1"/>
|
||||
<text x="206" y="646" text-anchor="middle" class="k" fill="#fffaf2" font-size="26">登录</text>
|
||||
<line x1="62" y1="692" x2="151" y2="692" stroke="#d1a965"/>
|
||||
<line x1="261" y1="692" x2="350" y2="692" stroke="#d1a965"/>
|
||||
<text x="206" y="699" text-anchor="middle" class="k gold" font-size="15">其他登录方式</text>
|
||||
<text x="219" y="761" text-anchor="middle" class="k body" font-size="20">微信登录</text>
|
||||
<text x="206" y="812" text-anchor="middle" class="k body" font-size="14">还没有账号? <tspan class="red">注册账号</tspan></text>
|
||||
<text x="93" y="851" class="k body" font-size="11">我已阅读并同意《用户协议》与《隐私政策》</text>
|
||||
</svg>`)
|
||||
|
||||
const sized = async (input, width, height) => sharp(input).resize(width, height, { fit: 'fill' }).png().toBuffer()
|
||||
|
||||
const buildBasePreview = async (mode) => {
|
||||
const stateIcon = mode === 'password' ? shared.lock : runtimeAssets.get('smsThreeDots')
|
||||
const rightIcon = mode === 'password' ? runtimeAssets.get('eyeClosedPupil') : null
|
||||
const layers = [
|
||||
{ input: await sized(runtimeAssets.get('header'), 412, 170), left: 0, top: 0 },
|
||||
{ input: await sized(runtimeAssets.get('scroll'), 392, 772), left: 10, top: 143 },
|
||||
{ input: await sized(shared.brandSeal, 72, 86), left: 170, top: 35 },
|
||||
{ input: await sized(runtimeAssets.get('titleOrnament'), 126, 44), left: 143, top: 235 },
|
||||
{ input: await sized(runtimeAssets.get('divider'), 180, 30), left: 116, top: 330 },
|
||||
{ input: await sized(runtimeAssets.get('phone'), 40, 40), left: 68, top: 420 },
|
||||
{ input: await sized(stateIcon, 40, 40), left: 68, top: 522 },
|
||||
{ input: await sized(runtimeAssets.get('primaryButton'), 292, 65), left: 60, top: 608 },
|
||||
{ input: await sized(runtimeAssets.get('secondaryButton'), 292, 60), left: 60, top: 718 },
|
||||
{ input: await sized(shared.wechat, 36, 36), left: 126, top: 730 },
|
||||
{ input: await sized(shared.agreementUnchecked, 24, 24), left: 62, top: 831 },
|
||||
{ input: svgLayer(mode), left: 0, top: 0 }
|
||||
]
|
||||
if (rightIcon) layers.splice(7, 0, { input: await sized(rightIcon, 42, 35), left: 312, top: 525 })
|
||||
return sharp({ create: { width: 412, height: 915, channels: 4, background: '#f4eadc' } })
|
||||
.composite(layers)
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
await mkdir(generatedDirectory, { recursive: true })
|
||||
const passwordPreview = await buildBasePreview('password')
|
||||
const smsPreview = await buildBasePreview('sms')
|
||||
|
||||
const writeGenerated = async (relativePath, buffer) => {
|
||||
const outputPath = resolveWorkspacePath(relativePath)
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
await writeFile(outputPath, buffer)
|
||||
}
|
||||
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-412x915.png', passwordPreview)
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-sms-412x915.png', smsPreview)
|
||||
const contact = await sharp({ create: { width: 824, height: 915, channels: 4, background: '#ffffff' } })
|
||||
.composite([{ input: passwordPreview, left: 0, top: 0 }, { input: smsPreview, left: 412, top: 0 }])
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated('design-pipeline/generated/a01/A01-password-sms-contact-824x915.png', contact)
|
||||
|
||||
for (const preview of manifest.previews.filter((item) => item.width !== 412 && item.width !== 824)) {
|
||||
const compact = await sharp(passwordPreview)
|
||||
.resize(preview.width, preview.height, { fit: 'fill' })
|
||||
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
||||
.toBuffer()
|
||||
await writeGenerated(preview.output, compact)
|
||||
}
|
||||
|
||||
const buildReport = {
|
||||
schemaVersion: 1,
|
||||
manifest: path.relative(workspace, manifestPath).replaceAll('\\', '/'),
|
||||
assets: reportAssets,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
await writeGenerated('design-pipeline/generated/a01/build-report.json', Buffer.from(`${JSON.stringify(buildReport, null, 2)}\n`))
|
||||
|
||||
console.log('A01-CODE-PIPELINE BUILD PASS')
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'g01-background-candidates.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'g01-background', 'build-report.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
if (manifest.schemaVersion !== 1 || manifest.page !== 'G01' || manifest.candidates?.length !== 4) {
|
||||
throw new Error('G01 background manifest must declare exactly four schema v1 candidates')
|
||||
}
|
||||
|
||||
for (const candidate of manifest.candidates) {
|
||||
const source = path.resolve(workspace, candidate.master)
|
||||
const relative = path.relative(workspace, source)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`${candidate.id} master escapes workspace`)
|
||||
if (!fs.existsSync(source)) throw new Error(`${candidate.id} master is missing: ${candidate.master}`)
|
||||
}
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'build_g01_backgrounds.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,51 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(scriptDirectory, '..', '..')
|
||||
const sourcePath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'opaque', 'g01-empty-panel.png')
|
||||
const outputPath = path.join(projectRoot, 'static', 'assets', 'modules', 'genealogy', 'transparent', 'g01-empty-panel-frame.png')
|
||||
const borderBand = 110
|
||||
|
||||
const { data, info } = await sharp(sourcePath)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
for (let y = 0; y < info.height; y += 1) {
|
||||
for (let x = 0; x < info.width; x += 1) {
|
||||
const offset = (y * info.width + x) * info.channels
|
||||
const red = data[offset]
|
||||
const green = data[offset + 1]
|
||||
const blue = data[offset + 2]
|
||||
const alpha = data[offset + 3]
|
||||
const distanceToEdge = Math.min(x, y, info.width - 1 - x, info.height - 1 - y)
|
||||
const isWarmGold = red > green
|
||||
&& green > blue
|
||||
&& red - green >= 15
|
||||
&& green - blue >= 12
|
||||
&& red - blue >= 35
|
||||
&& red < 245
|
||||
&& blue < 180
|
||||
|
||||
if (distanceToEdge >= borderBand || !isWarmGold) {
|
||||
data[offset + 3] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const edgeAlpha = Math.max(0, Math.min(255, (red - blue - 25) * 6))
|
||||
data[offset + 3] = Math.min(alpha, edgeAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
await sharp(data, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
channels: info.channels
|
||||
}
|
||||
}).png().toFile(outputPath)
|
||||
|
||||
process.stdout.write(`BUILT ${path.relative(projectRoot, outputPath)}\n`)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const argument = process.argv[2]
|
||||
if (!argument) throw new Error('用法:node build-raster-assets.mjs <仓库相对清单路径>')
|
||||
|
||||
// 清单参数本身也必须留在工作区;source/output 的边界由 schema validator 逐项负责。
|
||||
const manifestPath = path.resolve(workspace, argument)
|
||||
const relativeManifest = path.relative(workspace, manifestPath)
|
||||
if (relativeManifest.startsWith('..') || path.isAbsolute(relativeManifest)) {
|
||||
throw new Error(`构建清单越出工作区:${argument}`)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
const supportedModes = new Set(['opaque-resize', 'opaque-cover-crop', 'warm-gold-frame-extract'])
|
||||
for (const asset of manifest.assets) {
|
||||
if (!supportedModes.has(asset.processing.mode)) {
|
||||
throw new Error(`通用 raster 构建器不支持模式:${asset.processing.mode}`)
|
||||
}
|
||||
}
|
||||
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', manifest.family, 'quality-report.json')
|
||||
|
||||
// Node 只编排严格 schema、锁定的 Python 入口和质量报告;像素算法只存在于 Python。
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'build_raster_assets.py'), manifestPath, '--workspace', workspace],
|
||||
cwd: workspace,
|
||||
})
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
|
||||
// Node 只负责编排锁定的清单和 Python 工具;像素处理与质量分析各自只有一个实现。
|
||||
function run(script, args) {
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, script), ...args],
|
||||
cwd: workspace,
|
||||
})
|
||||
}
|
||||
|
||||
run('build_scroll_skins.py', [manifestPath, '--workspace', workspace])
|
||||
run('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Deterministically build G01 background candidates from saved ImageGen masters."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def process_chroma_image(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""Convert a green-screen master to clean RGBA with edge despill."""
|
||||
start = int(config["dominanceStart"])
|
||||
end = int(config["dominanceEnd"])
|
||||
allowance = int(config["despillAllowance"])
|
||||
if end <= start:
|
||||
raise ValueError("dominanceEnd must be greater than dominanceStart")
|
||||
|
||||
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
|
||||
converted = []
|
||||
for red, green, blue, source_alpha in source.convert("RGBA").get_flattened_data():
|
||||
dominance = green - max(red, blue)
|
||||
if green >= 100 and dominance > start:
|
||||
removal = min(1.0, (dominance - start) / (end - start))
|
||||
alpha = round(source_alpha * (1.0 - removal))
|
||||
if alpha <= 2:
|
||||
converted.append((0, 0, 0, 0))
|
||||
continue
|
||||
green = min(green, max(red, blue) + allowance)
|
||||
converted.append((red, green, blue, alpha))
|
||||
else:
|
||||
converted.append((red, green, blue, source_alpha))
|
||||
output.putdata(converted)
|
||||
return output
|
||||
|
||||
|
||||
def process_opaque_image(source: Image.Image, output_size: tuple[int, int] | None = None) -> Image.Image:
|
||||
"""Normalize a paper-backed master to deterministic opaque RGBA."""
|
||||
output = source.convert("RGBA")
|
||||
if output_size is not None and output.size != output_size:
|
||||
output = output.resize(output_size, Image.Resampling.LANCZOS)
|
||||
output.putalpha(255)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""Save a deterministic PNG with an explicit standard-sRGB chunk."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build(manifest_path: Path, workspace: Path, report_path: Path) -> list[dict[str, Any]]:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
chroma = manifest["chromaKey"]
|
||||
reports = []
|
||||
|
||||
for candidate in manifest["candidates"]:
|
||||
source_path = _resolve_inside(workspace, candidate["master"])
|
||||
output_path = _resolve_inside(workspace, candidate["generatedOutput"])
|
||||
with Image.open(source_path) as opened:
|
||||
expected = candidate["sourcePixels"]
|
||||
if opened.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{candidate['id']} expected {expected['width']}x{expected['height']}, got {opened.width}x{opened.height}"
|
||||
)
|
||||
if candidate["processingMode"] == "chroma-key":
|
||||
output = process_chroma_image(opened, chroma)
|
||||
elif candidate["processingMode"] in {"opaque-paper", "opaque-paper-resize"}:
|
||||
output_size = None
|
||||
if candidate["processingMode"] == "opaque-paper-resize":
|
||||
target = candidate["outputPixels"]
|
||||
output_size = (target["width"], target["height"])
|
||||
output = process_opaque_image(opened, output_size)
|
||||
else:
|
||||
raise ValueError(f"unsupported processingMode: {candidate['processingMode']}")
|
||||
|
||||
save_png(output, output_path)
|
||||
reports.append(
|
||||
{
|
||||
"id": candidate["id"],
|
||||
"mode": candidate["processingMode"],
|
||||
"source": candidate["master"],
|
||||
"output": candidate["generatedOutput"],
|
||||
"width": output.width,
|
||||
"height": output.height,
|
||||
"bytes": output_path.stat().st_size,
|
||||
"sha256": _sha256(output_path),
|
||||
}
|
||||
)
|
||||
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return reports
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
reports = build(args.manifest, args.workspace.resolve(), args.report)
|
||||
for report in reports:
|
||||
print(f"G01-BACKGROUND-BUILD PASS {report['id']} {report['width']}x{report['height']} {report['sha256']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = ROOT / "design-pipeline/manifests/module-page-backgrounds.json"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def normalize(source: Path, output: Path, width: int, height: int) -> None:
|
||||
with Image.open(source) as image:
|
||||
image = image.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
normalized = resized.crop((left, top, left + width, top + height))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
normalized.save(output, format="PNG", optimize=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
width = int(manifest["width"])
|
||||
height = int(manifest["height"])
|
||||
report = []
|
||||
for item in manifest["backgrounds"]:
|
||||
source = ROOT / item["source"]
|
||||
output = ROOT / item["output"]
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(source)
|
||||
normalize(source, output, width, height)
|
||||
report.append(
|
||||
{
|
||||
"module": item["module"],
|
||||
"source": item["source"],
|
||||
"output": item["output"],
|
||||
"size": [width, height],
|
||||
"sha256": sha256(output),
|
||||
}
|
||||
)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def _resolve_inside(workspace: Path, relative_path: str) -> Path:
|
||||
"""解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。"""
|
||||
absolute = (workspace / relative_path).resolve()
|
||||
try:
|
||||
absolute.relative_to(workspace.resolve())
|
||||
except ValueError as error:
|
||||
raise ValueError(f"path escapes workspace: {relative_path}") from error
|
||||
return absolute
|
||||
|
||||
|
||||
def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。"""
|
||||
image = source.convert("RGB")
|
||||
if image.size != output_size:
|
||||
image = image.resize(output_size, Image.Resampling.LANCZOS)
|
||||
return image
|
||||
|
||||
|
||||
def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image:
|
||||
"""等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。"""
|
||||
width, height = output_size
|
||||
image = source.convert("RGB")
|
||||
scale = max(width / image.width, height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - width) // 2)
|
||||
top = max(0, (resized.height - height) // 2)
|
||||
return resized.crop((left, top, left + width, top + height))
|
||||
|
||||
|
||||
def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image:
|
||||
"""复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。"""
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
border_band = int(config["borderBand"])
|
||||
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
|
||||
result: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()):
|
||||
x = index % width
|
||||
y = index // width
|
||||
distance_to_edge = min(x, y, width - 1 - x, height - 1 - y)
|
||||
warm_gold = (
|
||||
red > green > blue
|
||||
and red - green >= int(config["redGreenMin"])
|
||||
and green - blue >= int(config["greenBlueMin"])
|
||||
and red - blue >= int(config["redBlueMin"])
|
||||
and red < int(config["redMaxExclusive"])
|
||||
and blue < int(config["blueMaxExclusive"])
|
||||
)
|
||||
if distance_to_edge >= border_band or not warm_gold:
|
||||
result.append((0, 0, 0, 0))
|
||||
continue
|
||||
|
||||
edge_alpha = max(
|
||||
0,
|
||||
min(
|
||||
255,
|
||||
(red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]),
|
||||
),
|
||||
)
|
||||
alpha = min(source_alpha, edge_alpha)
|
||||
result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0))
|
||||
|
||||
output.putdata(result)
|
||||
return output
|
||||
|
||||
|
||||
def save_png(image: Image.Image, path: Path) -> None:
|
||||
"""以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info)
|
||||
|
||||
|
||||
def build_manifest(manifest_path: Path, workspace: Path) -> None:
|
||||
"""执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。"""
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
source_path = _resolve_inside(workspace, asset["source"])
|
||||
output_path = _resolve_inside(workspace, asset["output"])
|
||||
expected_source = asset["sourcePixels"]
|
||||
output_pixels = asset["outputPixels"]
|
||||
output_size = (int(output_pixels["width"]), int(output_pixels["height"]))
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected_size = (int(expected_source["width"]), int(expected_source["height"]))
|
||||
if source.size != expected_size:
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
|
||||
mode = asset["processing"]["mode"]
|
||||
if mode == "opaque-resize":
|
||||
output = build_opaque_resize(source, output_size)
|
||||
elif mode == "opaque-cover-crop":
|
||||
output = build_opaque_cover_crop(source, output_size)
|
||||
elif mode == "warm-gold-frame-extract":
|
||||
output = extract_warm_gold_frame(source, asset["processing"])
|
||||
else:
|
||||
raise ValueError(f"unsupported raster processing mode: {mode}")
|
||||
|
||||
if output.size != output_size:
|
||||
raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}")
|
||||
save_png(output, output_path)
|
||||
print(f"BUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
build_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
|
||||
"""从锁定的色键母版确定性生成项目共享卷轴资产。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -127,7 +127,16 @@ def build_asset(asset: dict, workspace: Path) -> None:
|
||||
source_path = workspace / asset["source"]
|
||||
output_path = workspace / asset["output"]
|
||||
|
||||
if processing["mode"] != "chroma-stretch":
|
||||
raise ValueError(f"unsupported scroll processing mode: {processing['mode']}")
|
||||
|
||||
with Image.open(source_path) as source:
|
||||
expected = asset["sourcePixels"]
|
||||
if source.size != (expected["width"], expected["height"]):
|
||||
raise ValueError(
|
||||
f"{asset['id']} source expected {expected['width']}x{expected['height']}, "
|
||||
f"got {source.width}x{source.height}"
|
||||
)
|
||||
cleaned = remove_chroma_background(
|
||||
source,
|
||||
parse_hex_color(processing["keyColor"]),
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const allowedAssetClasses = new Set([
|
||||
'code-native',
|
||||
'fixed-bitmap',
|
||||
'transparent-overlay',
|
||||
'nine-slice',
|
||||
'tile-texture'
|
||||
])
|
||||
|
||||
const requireObject = (value, label) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const requireString = (value, label) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
const requirePositiveNumber = (value, label) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive number`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveWorkspacePath = (workspace, relativePath, label) => {
|
||||
requireString(relativePath, label)
|
||||
const absolutePath = path.resolve(workspace, relativePath)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${relativePath}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const requireBoolean = (value, label) => {
|
||||
if (typeof value !== 'boolean') throw new Error(`${label} must be boolean`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const validateManifest = (manifest, workspace) => {
|
||||
requireObject(manifest, 'manifest')
|
||||
if (manifest.schemaVersion !== 2) throw new Error('schemaVersion must be 2')
|
||||
requireString(manifest.page, 'page')
|
||||
requireObject(manifest.runtime, 'runtime')
|
||||
requireString(manifest.runtime.url, 'runtime.url')
|
||||
requirePositiveNumber(manifest.runtime.chromePort, 'runtime.chromePort')
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) throw new Error('assets must be a non-empty array')
|
||||
|
||||
const ids = new Set()
|
||||
for (const asset of manifest.assets) {
|
||||
requireObject(asset, 'asset')
|
||||
const id = requireString(asset.id, 'asset.id')
|
||||
if (ids.has(id)) throw new Error(`duplicate asset id: ${id}`)
|
||||
ids.add(id)
|
||||
|
||||
if (!allowedAssetClasses.has(asset.assetClass)) throw new Error(`unsupported assetClass: ${asset.assetClass}`)
|
||||
resolveWorkspacePath(workspace, asset.source, `${id}.source`)
|
||||
resolveWorkspacePath(workspace, asset.output, `${id}.output`)
|
||||
|
||||
const logicalSlot = requireObject(asset.logicalSlot, `${id}.logicalSlot`)
|
||||
const outputPixels = requireObject(asset.outputPixels, `${id}.outputPixels`)
|
||||
const widthRpx = requirePositiveNumber(logicalSlot.widthRpx, `${id}.logicalSlot.widthRpx`)
|
||||
const heightRpx = requirePositiveNumber(logicalSlot.heightRpx, `${id}.logicalSlot.heightRpx`)
|
||||
const width = requirePositiveNumber(outputPixels.width, `${id}.outputPixels.width`)
|
||||
const height = requirePositiveNumber(outputPixels.height, `${id}.outputPixels.height`)
|
||||
|
||||
const render = requireObject(asset.render, `${id}.render`)
|
||||
requireString(render.scalePolicy, `${id}.render.scalePolicy`)
|
||||
requireString(render.uniMode, `${id}.render.uniMode`)
|
||||
requireBoolean(render.allowDistortion, `${id}.render.allowDistortion`)
|
||||
if (render.scalePolicy === 'uniform-only' && render.uniMode === 'scaleToFill') {
|
||||
throw new Error(`${id} uniform-only asset cannot use scaleToFill`)
|
||||
}
|
||||
const ratioDrift = Math.abs((width / height) / (widthRpx / heightRpx) - 1)
|
||||
if (asset.assetClass === 'fixed-bitmap' && ratioDrift > 0.005) {
|
||||
throw new Error(`${id} ratio drift exceeds 0.5%`)
|
||||
}
|
||||
|
||||
const alpha = requireObject(asset.alpha, `${id}.alpha`)
|
||||
requireBoolean(alpha.required, `${id}.alpha.required`)
|
||||
requirePositiveNumber(alpha.transparentOuterPadding, `${id}.alpha.transparentOuterPadding`)
|
||||
if (!Number.isInteger(alpha.cornerMaxAlpha) || alpha.cornerMaxAlpha < 0 || alpha.cornerMaxAlpha > 255) {
|
||||
throw new Error(`${id}.alpha.cornerMaxAlpha must be an integer from 0 to 255`)
|
||||
}
|
||||
|
||||
const edge = requireObject(asset.edge, `${id}.edge`)
|
||||
requireBoolean(edge.forbidChromaResidue, `${id}.edge.forbidChromaResidue`)
|
||||
requireBoolean(edge.forbidLightFringe, `${id}.edge.forbidLightFringe`)
|
||||
requireBoolean(edge.premultipliedAlphaCheck, `${id}.edge.premultipliedAlphaCheck`)
|
||||
|
||||
const quality = requireObject(asset.quality, `${id}.quality`)
|
||||
requirePositiveNumber(quality.maxBytes, `${id}.quality.maxBytes`)
|
||||
requireString(quality.colorSpace, `${id}.quality.colorSpace`)
|
||||
|
||||
const processing = requireObject(asset.processing, `${id}.processing`)
|
||||
requirePositiveNumber(processing.trim, `${id}.processing.trim`)
|
||||
requirePositiveNumber(processing.capWidth, `${id}.processing.capWidth`)
|
||||
|
||||
const runtime = requireObject(asset.runtime, `${id}.runtime`)
|
||||
requireString(runtime.selector, `${id}.runtime.selector`)
|
||||
requireString(runtime.imageSelector, `${id}.runtime.imageSelector`)
|
||||
|
||||
if (!Array.isArray(asset.consumers) || asset.consumers.length === 0) {
|
||||
throw new Error(`${id}.consumers must be a non-empty array`)
|
||||
}
|
||||
for (const consumer of asset.consumers) resolveWorkspacePath(workspace, consumer, `${id}.consumer`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
export const loadAndValidateManifest = async (filePath, workspace) => {
|
||||
const manifest = JSON.parse(await readFile(filePath, 'utf8'))
|
||||
return validateManifest(manifest, workspace)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function canRunPython(executable) {
|
||||
const result = spawnSync(executable, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return !result.error && result.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设计构建管线唯一可用的 Python 入口。
|
||||
*
|
||||
* 顺序必须保持稳定:显式配置 > 项目虚拟环境 > Windows Python Manager >
|
||||
* 系统命令。这样既尊重调用方选择,也不会把 WindowsApps 的零字节执行别名
|
||||
* 误判为真实解释器。每个候选都必须实际执行 `--version`,文件存在本身不算可用。
|
||||
*/
|
||||
export function resolvePythonExecutable({
|
||||
pipelineDirectory,
|
||||
environment = process.env,
|
||||
platform = process.platform,
|
||||
pathExists = fs.existsSync,
|
||||
canRun = canRunPython,
|
||||
} = {}) {
|
||||
if (!pipelineDirectory) throw new Error('解析 Python 入口时缺少 design-pipeline 目录')
|
||||
|
||||
const configured = environment.PYTHON?.trim()
|
||||
if (configured) {
|
||||
if (canRun(configured)) return configured
|
||||
throw new Error(`PYTHON 指定的解释器不可用:${configured}`)
|
||||
}
|
||||
|
||||
const pathApi = platform === 'win32' ? path.win32 : path.posix
|
||||
const candidates = []
|
||||
const virtualEnvironment = platform === 'win32'
|
||||
? pathApi.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
: pathApi.join(pipelineDirectory, '.venv', 'bin', 'python')
|
||||
if (pathExists(virtualEnvironment)) candidates.push(virtualEnvironment)
|
||||
|
||||
if (platform === 'win32' && environment.LOCALAPPDATA) {
|
||||
const managerPython = pathApi.join(environment.LOCALAPPDATA, 'Python', 'bin', 'python.exe')
|
||||
if (pathExists(managerPython)) candidates.push(managerPython)
|
||||
}
|
||||
|
||||
candidates.push(platform === 'win32' ? 'python' : 'python3', 'python')
|
||||
for (const candidate of new Set(candidates)) {
|
||||
if (canRun(candidate)) return candidate
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'未找到可用的 Python。请设置 PYTHON 为真实解释器路径,或在 design-pipeline/.venv 中安装项目虚拟环境。',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过统一入口执行 Python,保证任何构建和测试都不会向源码目录写入 `.pyc`。
|
||||
* 调用方只提供业务参数;`-B`、进程错误和退出码转换由这里集中负责。
|
||||
*/
|
||||
export function runPythonCommand({ executable, args, cwd, spawn = spawnSync } = {}) {
|
||||
if (typeof executable !== 'string' || executable.trim() === '') throw new Error('执行 Python 时缺少解释器')
|
||||
if (!Array.isArray(args)) throw new Error('执行 Python 时 args 必须为数组')
|
||||
if (typeof cwd !== 'string' || cwd.trim() === '') throw new Error('执行 Python 时缺少工作目录')
|
||||
|
||||
const result = spawn(executable, ['-B', ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) throw new Error(`无法启动 Python(${executable}):${result.error.message}`)
|
||||
if (result.status !== 0) throw new Error(`Python 命令执行失败,退出码:${result.status ?? 'unknown'}`)
|
||||
return result
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
function runPython(script, args) {
|
||||
const result = spawnSync(python, [path.join(scriptDirectory, script), ...args], {
|
||||
cwd: workspace,
|
||||
encoding: 'utf8',
|
||||
stdio: 'inherit',
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
}
|
||||
|
||||
runPython('rebuild_a01_buttons.py', [manifestPath, '--workspace', workspace])
|
||||
runPython('verify-assets.py', [manifestPath, '--workspace', workspace, '--report', reportPath])
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Rebuild A01 button skins with deterministic horizontal three-slice scaling."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def rebuild_button(
|
||||
source_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
output_size: tuple[int, int],
|
||||
trim: int,
|
||||
padding: int,
|
||||
cap_width: int,
|
||||
) -> None:
|
||||
"""Trim contaminated edges, preserve both caps, and stretch only the center."""
|
||||
with Image.open(source_path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
|
||||
if trim < 0 or trim * 2 >= min(source.size):
|
||||
raise ValueError(f"invalid trim {trim} for source size {source.size}")
|
||||
if trim:
|
||||
source = source.crop((trim, trim, source.width - trim, source.height - trim))
|
||||
|
||||
if cap_width <= 0 or cap_width * 2 >= source.width:
|
||||
raise ValueError(f"invalid capWidth {cap_width} for cropped width {source.width}")
|
||||
|
||||
output_width, output_height = output_size
|
||||
inner_width = output_width - padding * 2
|
||||
inner_height = output_height - padding * 2
|
||||
if inner_width <= 0 or inner_height <= 0:
|
||||
raise ValueError(f"padding {padding} leaves no drawable area in {output_size}")
|
||||
|
||||
target_cap_width = max(1, round(cap_width * inner_height / source.height))
|
||||
if target_cap_width * 2 >= inner_width:
|
||||
raise ValueError("scaled caps leave no room for the center slice")
|
||||
|
||||
left = source.crop((0, 0, cap_width, source.height))
|
||||
center = source.crop((cap_width, 0, source.width - cap_width, source.height))
|
||||
right = source.crop((source.width - cap_width, 0, source.width, source.height))
|
||||
|
||||
resampling = Image.Resampling.LANCZOS
|
||||
left = left.resize((target_cap_width, inner_height), resampling)
|
||||
center = center.resize((inner_width - target_cap_width * 2, inner_height), resampling)
|
||||
right = right.resize((target_cap_width, inner_height), resampling)
|
||||
|
||||
output = Image.new("RGBA", output_size, (0, 0, 0, 0))
|
||||
output.alpha_composite(left, (padding, padding))
|
||||
output.alpha_composite(center, (padding + target_cap_width, padding))
|
||||
output.alpha_composite(right, (output_width - padding - target_cap_width, padding))
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add(b"sRGB", b"\x00")
|
||||
output.save(output_path, format="PNG", optimize=True, pnginfo=png_info)
|
||||
|
||||
|
||||
def rebuild_manifest(manifest_path: Path, workspace_root: Path) -> None:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
for asset in manifest["assets"]:
|
||||
processing = asset["processing"]
|
||||
alpha = asset["alpha"]
|
||||
pixels = asset["outputPixels"]
|
||||
source = workspace_root / asset["source"]
|
||||
output = workspace_root / asset["output"]
|
||||
rebuild_button(
|
||||
source,
|
||||
output,
|
||||
output_size=(pixels["width"], pixels["height"]),
|
||||
trim=processing["trim"],
|
||||
padding=alpha["transparentOuterPadding"],
|
||||
cap_width=processing["capWidth"],
|
||||
)
|
||||
print(f"REBUILT {asset['id']} -> {asset['output']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
rebuild_manifest(args.manifest.resolve(), args.workspace.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: ['-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'],
|
||||
cwd: pipelineDirectory,
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const formalManifestKinds = new Set(['asset-build-manifest', 'runtime-asset-inventory'])
|
||||
const assertOnlyFields = (value, fields, label) => {
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!fields.has(field)) throw new Error(`${label} contains unknown field: ${field}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveInsideWorkspace = (workspace, candidate, label) => {
|
||||
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
||||
throw new Error(`${label} must be a non-empty string`)
|
||||
}
|
||||
const absolutePath = path.resolve(workspace, candidate)
|
||||
const relative = path.relative(workspace, absolutePath)
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`${label} escapes workspace: ${candidate}`)
|
||||
}
|
||||
return absolutePath
|
||||
}
|
||||
|
||||
const readManifest = async (manifestPath) => {
|
||||
try {
|
||||
return JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') throw new Error(`missing manifest: ${manifestPath}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const validateDirectAsset = (asset, workspace) => {
|
||||
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('runtime asset must be an object')
|
||||
assertOnlyFields(
|
||||
asset,
|
||||
new Set(['id', 'output', 'width', 'height', 'alpha', 'bytes', 'sha256', 'provenance', 'rebuildable']),
|
||||
'runtime asset',
|
||||
)
|
||||
for (const key of ['id', 'output']) {
|
||||
if (typeof asset[key] !== 'string' || asset[key].trim() === '') {
|
||||
throw new Error(`runtime asset ${key} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
resolveInsideWorkspace(workspace, asset.output, `${asset.id}.output`)
|
||||
for (const key of ['width', 'height', 'bytes']) {
|
||||
if (!Number.isInteger(asset[key]) || asset[key] <= 0) throw new Error(`${asset.id}.${key} must be a positive integer`)
|
||||
}
|
||||
if (typeof asset.alpha !== 'boolean') throw new Error(`${asset.id}.alpha must be boolean`)
|
||||
if (asset.provenance !== 'committed-binary') {
|
||||
throw new Error(`${asset.id}.provenance must be committed-binary`)
|
||||
}
|
||||
if (asset.rebuildable !== false) throw new Error(`${asset.id}.rebuildable must be false`)
|
||||
if (!/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error(`${asset.id}.sha256 must be lowercase SHA256`)
|
||||
return asset
|
||||
}
|
||||
|
||||
export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) => {
|
||||
const workspacePath = path.resolve(workspace)
|
||||
const loaded = new Set()
|
||||
const stack = []
|
||||
const ids = new Map()
|
||||
const outputs = new Map()
|
||||
const manifests = []
|
||||
|
||||
const addAsset = (asset, owner) => {
|
||||
if (ids.has(asset.id)) {
|
||||
throw new Error(`duplicate asset id: ${asset.id} (${ids.get(asset.id)}, ${owner})`)
|
||||
}
|
||||
if (outputs.has(asset.output)) {
|
||||
throw new Error(`duplicate output: ${asset.output} (${outputs.get(asset.output).owner}, ${owner})`)
|
||||
}
|
||||
ids.set(asset.id, owner)
|
||||
outputs.set(asset.output, { ...asset, owner })
|
||||
}
|
||||
|
||||
// 递归展开只处理机器清单之间的依赖;页面消费者始终从源码扫描得到,避免双写。
|
||||
const visit = async (manifestPath) => {
|
||||
const absolutePath = resolveInsideWorkspace(workspacePath, manifestPath, 'manifest')
|
||||
if (stack.includes(absolutePath)) {
|
||||
throw new Error(`import cycle: ${[...stack, absolutePath].join(' -> ')}`)
|
||||
}
|
||||
if (loaded.has(absolutePath)) throw new Error(`duplicate import: ${absolutePath}`)
|
||||
|
||||
stack.push(absolutePath)
|
||||
loaded.add(absolutePath)
|
||||
manifests.push(absolutePath)
|
||||
const manifest = await readManifest(absolutePath)
|
||||
|
||||
if (manifest.kind === 'asset-build-manifest') {
|
||||
validateAssetBuildManifest(manifest, workspacePath)
|
||||
for (const asset of manifest.assets) {
|
||||
addAsset(
|
||||
{
|
||||
id: asset.id,
|
||||
output: asset.output,
|
||||
width: asset.outputPixels.width,
|
||||
height: asset.outputPixels.height,
|
||||
// 不透明构建模式依法不声明 alpha;只有透明模式存在该对象,避免把“字段缺失”误判为清单损坏。
|
||||
alpha: asset.alpha?.required ?? false,
|
||||
maxBytes: asset.quality.maxBytes,
|
||||
provenance: 'generated-from-manifest',
|
||||
rebuildable: true,
|
||||
},
|
||||
absolutePath,
|
||||
)
|
||||
}
|
||||
} else if (manifest.kind === 'runtime-asset-inventory') {
|
||||
assertOnlyFields(manifest, new Set(['schemaVersion', 'kind', 'scope', 'imports', 'assets']), 'runtime manifest')
|
||||
if (manifest.schemaVersion !== 1) throw new Error('runtime inventory schemaVersion must be 1')
|
||||
if (typeof manifest.scope !== 'string' || manifest.scope.trim() === '') throw new Error('runtime inventory scope is required')
|
||||
if (!Array.isArray(manifest.imports) || !Array.isArray(manifest.assets)) {
|
||||
throw new Error('runtime inventory imports and assets must be arrays')
|
||||
}
|
||||
for (const asset of manifest.assets) addAsset(validateDirectAsset(asset, workspacePath), absolutePath)
|
||||
for (const imported of manifest.imports) await visit(imported)
|
||||
} else {
|
||||
throw new Error(`unsupported manifest kind: ${manifest.kind}`)
|
||||
}
|
||||
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
await visit(path.relative(workspacePath, path.resolve(rootManifestPath)))
|
||||
return { manifests, assets: [...outputs.values()] }
|
||||
}
|
||||
|
||||
export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace, manifestsDirectory) => {
|
||||
const workspacePath = path.resolve(workspace)
|
||||
const directory = resolveInsideWorkspace(workspacePath, manifestsDirectory, 'manifests directory')
|
||||
const rootManifest = await readManifest(path.resolve(rootManifestPath))
|
||||
if (rootManifest.kind !== 'runtime-asset-inventory' || rootManifest.scope !== 'schema-v3') {
|
||||
throw new Error('registry root scope must be schema-v3')
|
||||
}
|
||||
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
|
||||
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
|
||||
|
||||
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
|
||||
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== '.json') continue
|
||||
const manifestPath = path.join(directory, entry.name)
|
||||
const manifest = await readManifest(manifestPath)
|
||||
if (formalManifestKinds.has(manifest.kind)) {
|
||||
if (!registered.has(path.resolve(manifestPath))) throw new Error(`unregistered manifest: ${manifestPath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`undeclared legacy manifest: ${manifestPath}`)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-asset-build-manifest.mjs <workspace-relative-manifest>')
|
||||
|
||||
const manifestPath = path.resolve(workspace, manifestArgument)
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
process.stdout.write(`ASSET-BUILD-MANIFEST PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-manifest-v2.mjs <manifest>')
|
||||
|
||||
const manifestPath = path.resolve(pipelineDirectory, manifestArgument)
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
process.stdout.write(`MANIFEST-V2 PASS ${path.relative(workspace, manifestPath).replaceAll('\\', '/')}\n`)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateRuntimeAssetRegistry } from './runtime-asset-inventory.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(scriptDirectory, '..', '..')
|
||||
const manifestArgument = process.argv[2]
|
||||
if (!manifestArgument) throw new Error('Usage: node validate-runtime-asset-inventory.mjs <workspace-relative-manifest>')
|
||||
|
||||
const inventory = await validateRuntimeAssetRegistry(
|
||||
path.resolve(workspace, manifestArgument),
|
||||
workspace,
|
||||
path.join(workspace, 'design-pipeline', 'manifests'),
|
||||
)
|
||||
process.stdout.write(`${JSON.stringify(inventory)}\n`)
|
||||
@@ -1,22 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-scroll-skins-v3', 'quality-report.json')
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,24 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadAndValidateManifest } from './manifest-v2.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-buttons-v2.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'a01-buttons-v2', 'quality-report.json')
|
||||
|
||||
await loadAndValidateManifest(manifestPath, workspace)
|
||||
|
||||
const localPython = path.join(pipelineDirectory, '.venv', 'Scripts', 'python.exe')
|
||||
const python = process.env.PYTHON || (fs.existsSync(localPython) ? localPython : 'python')
|
||||
const result = spawnSync(
|
||||
python,
|
||||
[path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
{ cwd: workspace, encoding: 'utf8', stdio: 'inherit' }
|
||||
)
|
||||
|
||||
if (result.error) throw new Error(`无法启动 Python(${python}):${result.error.message}`)
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Verify generated assets declared in a manifest v2 file."""
|
||||
"""校验 schema v3 生成型资产,并写出不含机器绝对路径的确定性报告。"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
@@ -23,13 +23,13 @@ def main() -> None:
|
||||
if not output.is_file():
|
||||
report = {
|
||||
"id": asset["id"],
|
||||
"path": str(output),
|
||||
"path": Path(asset["output"]).as_posix(),
|
||||
"errors": ["output file is missing"],
|
||||
"warnings": [],
|
||||
"metrics": {},
|
||||
}
|
||||
else:
|
||||
report = analyze_asset(output, asset)
|
||||
report = analyze_asset(output, asset, args.workspace)
|
||||
reports.append(report)
|
||||
if report["errors"]:
|
||||
failed = True
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { validateAssetBuildManifest } from './asset-build-manifest.mjs'
|
||||
import { resolvePythonExecutable, runPythonCommand } from './python-runtime.mjs'
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(scriptDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'shared-scroll-skins-v3.json')
|
||||
const reportPath = path.join(pipelineDirectory, 'generated', 'shared-scroll-skins-v3', 'quality-report.json')
|
||||
const python = resolvePythonExecutable({ pipelineDirectory })
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
|
||||
validateAssetBuildManifest(manifest, workspace)
|
||||
runPythonCommand({
|
||||
executable: python,
|
||||
args: [path.join(scriptDirectory, 'verify-assets.py'), manifestPath, '--workspace', workspace, '--report', reportPath],
|
||||
cwd: workspace,
|
||||
})
|
||||
Reference in New Issue
Block a user