视觉审核完成30%

This commit is contained in:
rain
2026-07-16 18:12:59 +08:00
parent cb25317412
commit 23cfd365a1
89 changed files with 3740 additions and 403 deletions
+98
View File
@@ -0,0 +1,98 @@
"""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]) -> dict[str, Any]:
"""Analyze one PNG against its manifest v2 specification."""
path = Path(path)
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", 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())
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):
warnings.append("PNG does not declare an sRGB chunk or ICC profile")
return {
"id": spec.get("id", path.stem),
"path": str(path),
"width": width,
"height": height,
"mode": mode,
"bytes": byte_size,
"errors": errors,
"warnings": warnings,
"metrics": metrics,
}
@@ -0,0 +1,28 @@
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])
@@ -0,0 +1,33 @@
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 !== 3) {
throw new Error('G01 background manifest must declare exactly three 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)
@@ -0,0 +1,122 @@
"""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) -> Image.Image:
"""Normalize a paper-backed master to deterministic opaque RGBA."""
output = source.convert("RGBA")
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"] == "opaque-paper":
output = process_opaque_image(opened)
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()
@@ -0,0 +1,176 @@
"""Build exact A01 scroll-skin outputs from chroma-backed visual masters."""
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"]
with Image.open(source_path) as source:
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()
+121
View File
@@ -0,0 +1,121 @@
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,31 @@
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])
@@ -0,0 +1,89 @@
"""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,15 @@
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,22 @@
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)
+24
View File
@@ -0,0 +1,24 @@
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)
+47
View File
@@ -0,0 +1,47 @@
"""Verify generated assets declared in a manifest v2 file."""
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": str(output),
"errors": ["output file is missing"],
"warnings": [],
"metrics": {},
}
else:
report = analyze_asset(output, asset)
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()