chore: reduce design pipeline to runtime asset checks

This commit is contained in:
2026-08-12 18:24:40 +08:00
parent 964262f693
commit 5dabcd175e
30 changed files with 106 additions and 1698 deletions
@@ -1,196 +0,0 @@
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
}
-127
View File
@@ -1,127 +0,0 @@
"""Deterministic PNG quality checks for generated design assets."""
from pathlib import Path
from typing import Any
from PIL import Image
def _outer_ring_pixels(image: Image.Image, thickness: int):
width, height = image.size
thickness = max(0, min(thickness, width // 2, height // 2))
for y in range(height):
for x in range(width):
if x < thickness or x >= width - thickness or y < thickness or y >= height - thickness:
yield image.getpixel((x, y))
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] = {}
with Image.open(path) as opened:
width, height = opened.size
mode = opened.mode
info = dict(opened.info)
has_alpha = "A" in opened.getbands() or "transparency" in opened.info
image = opened.convert("RGBA")
expected = spec["outputPixels"]
if (width, height) != (expected["width"], expected["height"]):
errors.append(
f"dimensions expected {expected['width']}x{expected['height']}, got {width}x{height}"
)
alpha = spec.get("alpha", {})
if alpha.get("required") and not has_alpha:
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", 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", {})
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
for red, green, blue, pixel_alpha in pixels
if pixel_alpha > 8 and green > 120 and green - max(red, blue) > 80
)
metrics["chromaResiduePixels"] = chroma_residue
if edge.get("forbidChromaResidue") and chroma_residue:
errors.append(f"chroma residue detected in {chroma_residue} visible pixels")
light_fringe = sum(
1
for red, green, blue, pixel_alpha in pixels
if 0 < pixel_alpha < 255 and red > 235 and green > 235 and blue > 235
)
metrics["lightFringePixels"] = light_fringe
if edge.get("forbidLightFringe") and light_fringe:
errors.append(f"light fringe detected in {light_fringe} partially transparent pixels")
transparent_rgb = sum(
1
for red, green, blue, pixel_alpha in pixels
if pixel_alpha == 0 and (red != 0 or green != 0 or blue != 0)
)
metrics["transparentRgbPixels"] = transparent_rgb
if edge.get("premultipliedAlphaCheck") and transparent_rgb:
errors.append(f"transparent RGB detected in {transparent_rgb} fully transparent pixels")
byte_size = path.stat().st_size
max_bytes = int(spec.get("quality", {}).get("maxBytes", 0))
if max_bytes and byte_size > max_bytes:
errors.append(f"maxBytes {max_bytes} exceeded by file size {byte_size}")
expected_color_space = spec.get("quality", {}).get("colorSpace")
if expected_color_space == "sRGB" and not ("srgb" in info or "icc_profile" in info):
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": report_path,
"width": width,
"height": height,
"mode": mode,
"bytes": byte_size,
"errors": errors,
"warnings": warnings,
"metrics": metrics,
}
@@ -1,43 +0,0 @@
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,
})
@@ -1,28 +0,0 @@
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,132 +0,0 @@
"""按 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,185 +0,0 @@
"""从锁定的色键母版确定性生成项目共享卷轴资产。"""
import argparse
import json
import math
from pathlib import Path
from PIL import Image, PngImagePlugin
def remove_chroma_background(
image: Image.Image,
key: tuple[int, int, int],
tolerance: int,
feather: int = 72,
) -> Image.Image:
source = image.convert("RGBA")
cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0))
result = []
key_red, key_green, key_blue = key
for red, green, blue, source_alpha in source.get_flattened_data():
distance = math.sqrt(
(red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2
)
alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather))
alpha_fraction *= source_alpha / 255
if alpha_fraction <= 0:
result.append((0, 0, 0, 0))
continue
recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction)
recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction)
recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction)
result.append(
(
max(0, min(255, recovered_red)),
max(0, min(255, recovered_green)),
max(0, min(255, recovered_blue)),
round(alpha_fraction * 255),
)
)
cleaned.putdata(result)
return cleaned
def trim_transparent(image: Image.Image) -> Image.Image:
alpha = image.getchannel("A")
bounds = alpha.getbbox()
if bounds is None:
raise ValueError("chroma removal left no visible artwork")
return image.crop(bounds)
def stretch_safe_center(
image: Image.Image,
*,
output_size: tuple[int, int],
cap_width: int,
padding: int,
) -> Image.Image:
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("transparent padding leaves no drawable area")
if cap_width <= 0 or cap_width * 2 >= image.width:
raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}")
target_cap_width = max(1, round(cap_width * inner_height / image.height))
center_width = inner_width - target_cap_width * 2
if center_width <= 0:
raise ValueError("scaled caps leave no room for the stretch-safe center")
left = image.crop((0, 0, cap_width, image.height))
center = image.crop((cap_width, 0, image.width - cap_width, image.height))
right = image.crop((image.width - cap_width, 0, image.width, image.height))
resampling = Image.Resampling.LANCZOS
left = left.resize((target_cap_width, inner_height), resampling)
center = center.resize((center_width, 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))
return output
def sanitize_output_edges(image: Image.Image) -> Image.Image:
cleaned = []
for red, green, blue, alpha in image.convert("RGBA").get_flattened_data():
chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80
light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235
if alpha == 0 or chroma_residue or light_fringe:
cleaned.append((0, 0, 0, 0))
else:
cleaned.append((red, green, blue, alpha))
output = Image.new("RGBA", image.size, (0, 0, 0, 0))
output.putdata(cleaned)
return output
def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image:
alpha = image.getchannel("A")
rgb = image.convert("RGB").quantize(
colors=colors,
method=Image.Quantize.MEDIANCUT,
dither=Image.Dither.NONE,
).convert("RGB")
output = rgb.convert("RGBA")
output.putalpha(alpha)
return sanitize_output_edges(output)
def parse_hex_color(value: str) -> tuple[int, int, int]:
if len(value) != 7 or not value.startswith("#"):
raise ValueError(f"invalid RGB hex color: {value}")
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
def build_asset(asset: dict, workspace: Path) -> None:
processing = asset["processing"]
alpha = asset["alpha"]
pixels = asset["outputPixels"]
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"]),
int(processing["keyTolerance"]),
)
artwork = trim_transparent(cleaned)
output = stretch_safe_center(
artwork,
output_size=(pixels["width"], pixels["height"]),
cap_width=int(processing["capWidth"]),
padding=int(alpha["transparentOuterPadding"]),
)
palette_colors = int(processing.get("paletteColors", 192))
output = sanitize_output_edges(output)
if processing.get("indexedPng"):
output = output.quantize(
colors=palette_colors,
method=Image.Quantize.FASTOCTREE,
dither=Image.Dither.NONE,
)
else:
output = reduce_png_palette(output, colors=palette_colors)
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)
print(f"BUILT {asset['id']} -> {asset['output']}")
def build_manifest(manifest_path: Path, workspace: Path) -> None:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for asset in manifest["assets"]:
build_asset(asset, workspace)
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,74 +0,0 @@
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,14 +0,0 @@
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,
})
@@ -1,9 +1,8 @@
import { createHash } from 'node:crypto'
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 formalManifestKinds = new Set(['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}`)
@@ -88,25 +87,7 @@ export const expandRuntimeAssetInventory = async (rootManifestPath, workspace) =
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') {
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')
@@ -136,6 +117,23 @@ export const validateRuntimeAssetRegistry = async (rootManifestPath, workspace,
const inventory = await expandRuntimeAssetInventory(rootManifestPath, workspacePath)
const registered = new Set(inventory.manifests.map((manifest) => path.resolve(manifest)))
for (const asset of inventory.assets) {
let binary
try {
binary = await readFile(resolveInsideWorkspace(workspacePath, asset.output, `${asset.id}.output`))
} catch (error) {
if (error.code === 'ENOENT') throw new Error(`missing runtime asset: ${asset.output}`)
throw error
}
if (binary.length !== asset.bytes) {
throw new Error(`${asset.id}.bytes does not match ${asset.output}`)
}
const digest = createHash('sha256').update(binary).digest('hex')
if (digest !== asset.sha256) {
throw new Error(`${asset.id}.sha256 does not match ${asset.output}`)
}
}
// 顶层注册表必须覆盖目录内每一份正式 owner。这样新增清单若没有接入全局图会立即失败,
// output 与 id 的唯一性也就不再局限于某个业务域的 imports 闭包。
for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -1,15 +0,0 @@
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`)
@@ -13,4 +13,4 @@ const inventory = await validateRuntimeAssetRegistry(
workspace,
path.join(workspace, 'design-pipeline', 'manifests'),
)
process.stdout.write(`${JSON.stringify(inventory)}\n`)
process.stdout.write(`RUNTIME ASSET CHECK PASS manifests=${inventory.manifests.length} assets=${inventory.assets.length}\n`)
-47
View File
@@ -1,47 +0,0 @@
"""校验 schema v3 生成型资产,并写出不含机器绝对路径的确定性报告。"""
import argparse
import json
import sys
from pathlib import Path
from asset_quality import analyze_asset
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()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
reports = []
failed = False
for asset in manifest["assets"]:
output = args.workspace / asset["output"]
if not output.is_file():
report = {
"id": asset["id"],
"path": Path(asset["output"]).as_posix(),
"errors": ["output file is missing"],
"warnings": [],
"metrics": {},
}
else:
report = analyze_asset(output, asset, args.workspace)
reports.append(report)
if report["errors"]:
failed = True
print(f"ASSET-QUALITY FAIL {asset['id']}: {'; '.join(report['errors'])}")
else:
print(f"ASSET-QUALITY PASS {asset['id']}")
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8")
if failed:
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,21 +0,0 @@
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,
})