视觉审核完成30%
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-primary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-primary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-primary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 8, "capWidth": 180 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-secondary-button-v2",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "static/assets/foundation/opaque/a01-secondary-button.png",
|
||||
"output": "static/assets/foundation/transparent/a01-secondary-button-v2.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 12, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1200000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 12, "capWidth": 200 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"page": "A01",
|
||||
"runtime": {
|
||||
"url": "http://localhost:5173/#/pages/auth/a01-entry",
|
||||
"chromePort": 9222
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "a01-scroll-primary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-primary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-primary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 92 },
|
||||
"outputPixels": { "width": 1866, "height": 276 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".login-submit", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-secondary-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-secondary-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-secondary-v3.png",
|
||||
"logicalSlot": { "widthRpx": 622, "heightRpx": 100 },
|
||||
"outputPixels": { "width": 1866, "height": 300 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1800000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 380, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".wechat-login", "imageSelector": ".button-skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-toast-v3",
|
||||
"assetClass": "nine-slice",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-toast-master-v3.png",
|
||||
"output": "static/assets/foundation/transparent/a01-scroll-toast-v3.png",
|
||||
"logicalSlot": { "widthRpx": 590, "heightRpx": 82 },
|
||||
"outputPixels": { "width": 1770, "height": 246 },
|
||||
"render": { "scalePolicy": "nine-slice", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 1600000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 340, "keyColor": "#00FF00", "keyTolerance": 96 },
|
||||
"runtime": { "selector": ".feedback-toast", "imageSelector": ".feedback-toast__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
},
|
||||
{
|
||||
"id": "a01-scroll-dialog-v3",
|
||||
"assetClass": "fixed-bitmap",
|
||||
"source": "docs/design/assets/a01-vnext/masters/a01-scroll-dialog-master-v3-r2.png",
|
||||
"output": "static/assets/modules/auth/transparent/a01-scroll-dialog-v3.png",
|
||||
"logicalSlot": { "widthRpx": 620, "heightRpx": 520 },
|
||||
"outputPixels": { "width": 1860, "height": 1560 },
|
||||
"render": { "scalePolicy": "uniform-only", "uniMode": "aspectFit", "allowDistortion": false },
|
||||
"alpha": { "required": true, "transparentOuterPadding": 6, "cornerMaxAlpha": 0 },
|
||||
"edge": { "forbidChromaResidue": true, "forbidLightFringe": true, "premultipliedAlphaCheck": true },
|
||||
"quality": { "maxBytes": 3000000, "symmetry": "horizontal", "colorSpace": "sRGB" },
|
||||
"processing": { "trim": 1, "capWidth": 260, "keyColor": "#00FF00", "keyTolerance": 96, "paletteColors": 128, "indexedPng": true },
|
||||
"runtime": { "selector": ".verification-dialog", "imageSelector": ".verification-dialog__skin img" },
|
||||
"consumers": ["pages/auth/a01-entry.vue"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"page": "G01",
|
||||
"status": "candidates-pending-user-selection",
|
||||
"generatedDate": "2026-07-16",
|
||||
"referenceAssets": [
|
||||
"static/assets/foundation/transparent/footer-mountain-bamboo.png",
|
||||
"tmp/g01-audit-02-application-gap-focused-412x915.png"
|
||||
],
|
||||
"determinism": {
|
||||
"modelRegeneration": "direction-only-not-pixel-identical",
|
||||
"savedMasterProcessing": "pixel-identical-with-locked-dependencies"
|
||||
},
|
||||
"chromaKey": {
|
||||
"color": "#00ff00",
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"id": "g01-list-background-a",
|
||||
"title": "上山下亭纵向框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-a-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-a.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖灰淡金中国水墨,远山从上方约 10% 开始,左中下亭台,右侧竹影贯穿,中央低对比度供列表文字阅读;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-b",
|
||||
"title": "上下山水环抱框景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-b-chroma-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-b.png",
|
||||
"processingMode": "chroma-key",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;上方连续远山与雾带、左中亭台、右侧淡金竹影、下方第二层山水,三个纵向区段都有内容但中央保持安静;无文字、无 UI、无卡片、无红色;非画面区域必须为纯 #00ff00 绿幕,画面本身不得使用绿色。"
|
||||
},
|
||||
{
|
||||
"id": "g01-list-background-c",
|
||||
"title": "云竹亭台连续宣纸景",
|
||||
"master": "docs/design/assets/g01-background/masters/g01-list-background-direction-c-paper-master.png",
|
||||
"generatedOutput": "design-pipeline/generated/g01-background/g01-list-background-direction-c.png",
|
||||
"processingMode": "opaque-paper",
|
||||
"sourcePixels": { "width": 1024, "height": 1536 },
|
||||
"prompt": "1024x1536 纵向 G01 列表背景独立资产;暖宣纸底,顶部 5% 即出现云气、枝叶和淡金竹影,左侧约 40% 高度放置亭台,山水从中部连续延伸到底部,中央低对比度;无文字、无 UI、无卡片、无标志、无红色。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,15 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:a01": "node scripts/build-a01.mjs"
|
||||
"build:a01": "node scripts/build-a01.mjs",
|
||||
"test:v2": "node --test tests/*.test.mjs",
|
||||
"validate:a01-buttons": "node scripts/validate-manifest-v2.mjs manifests/a01-buttons-v2.json",
|
||||
"validate:a01-scroll-skins": "node scripts/validate-manifest-v2.mjs manifests/a01-scroll-skins-v3.json",
|
||||
"rebuild:a01-buttons": "node scripts/rebuild-a01-buttons.mjs",
|
||||
"verify:assets": "node scripts/verify-assets.mjs",
|
||||
"build:a01-scroll-skins": "node scripts/build-a01-scroll-skins.mjs",
|
||||
"verify:a01-scroll-skins": "node scripts/verify-a01-scroll-skins.mjs",
|
||||
"build:g01-background-candidates": "node scripts/build-g01-backgrounds.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"sharp": "0.34.5"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Pillow==12.3.0
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
const manifestPath = path.join(workspace, 'design-pipeline', 'manifests', 'g01-background-candidates.json')
|
||||
const wrapperPath = path.join(workspace, 'design-pipeline', 'scripts', 'build-g01-backgrounds.mjs')
|
||||
|
||||
test('G01 background candidate manifest preserves three portable masters', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
assert.equal(manifest.schemaVersion, 1)
|
||||
assert.equal(manifest.page, 'G01')
|
||||
assert.equal(manifest.status, 'candidates-pending-user-selection')
|
||||
assert.equal(manifest.candidates.length, 3)
|
||||
|
||||
const ids = new Set()
|
||||
for (const candidate of manifest.candidates) {
|
||||
assert.ok(!ids.has(candidate.id), `duplicate candidate id: ${candidate.id}`)
|
||||
ids.add(candidate.id)
|
||||
assert.match(candidate.prompt, /1024x1536/)
|
||||
assert.deepEqual(candidate.sourcePixels, { width: 1024, height: 1536 })
|
||||
assert.ok(['chroma-key', 'opaque-paper'].includes(candidate.processingMode))
|
||||
|
||||
for (const key of ['master', 'generatedOutput']) {
|
||||
const absolute = path.resolve(workspace, candidate[key])
|
||||
const relative = path.relative(workspace, absolute)
|
||||
assert.ok(!relative.startsWith('..') && !path.isAbsolute(relative), `${key} escapes workspace`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('G01 Node wrapper delegates pixel decoding to locked Pillow without Sharp', async () => {
|
||||
const wrapper = await readFile(wrapperPath, 'utf8')
|
||||
assert.doesNotMatch(wrapper, /from ['"]sharp['"]/)
|
||||
assert.match(wrapper, /build_g01_backgrounds\.py/)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const workspace = path.resolve(testDirectory, '..', '..')
|
||||
|
||||
const validAsset = () => ({
|
||||
id: 'a01-primary-button-v2',
|
||||
assetClass: 'fixed-bitmap',
|
||||
source: 'static/assets/foundation/opaque/a01-primary-button.png',
|
||||
output: 'static/assets/foundation/transparent/a01-primary-button-v2.png',
|
||||
logicalSlot: { widthRpx: 622, heightRpx: 92 },
|
||||
outputPixels: { width: 1866, height: 276 },
|
||||
render: { scalePolicy: 'uniform-only', uniMode: 'aspectFit', allowDistortion: false },
|
||||
alpha: { required: true, transparentOuterPadding: 12, cornerMaxAlpha: 0 },
|
||||
edge: { forbidChromaResidue: true, forbidLightFringe: true, premultipliedAlphaCheck: true },
|
||||
quality: { maxBytes: 500000, symmetry: 'horizontal', colorSpace: 'sRGB' },
|
||||
processing: { trim: 8, capWidth: 180 },
|
||||
runtime: { selector: '.login-submit', imageSelector: '.button-skin img' },
|
||||
consumers: ['pages/auth/a01-entry.vue']
|
||||
})
|
||||
|
||||
const validManifest = () => ({
|
||||
schemaVersion: 2,
|
||||
page: 'A01',
|
||||
runtime: { url: 'http://localhost:5173/#/pages/auth/a01-entry', chromePort: 9222 },
|
||||
assets: [validAsset()]
|
||||
})
|
||||
|
||||
test('accepts a complete manifest v2', () => {
|
||||
const manifest = validManifest()
|
||||
assert.equal(validateManifest(manifest, workspace), manifest)
|
||||
})
|
||||
|
||||
test('rejects duplicate asset ids', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets.push(validAsset())
|
||||
assert.throws(() => validateManifest(manifest, workspace), /duplicate asset id/i)
|
||||
})
|
||||
|
||||
test('rejects paths that escape the workspace', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].output = '../outside.png'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /escapes workspace/i)
|
||||
})
|
||||
|
||||
test('rejects a fixed bitmap whose output ratio differs from its slot', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].outputPixels.height = 300
|
||||
assert.throws(() => validateManifest(manifest, workspace), /ratio drift/i)
|
||||
})
|
||||
|
||||
test('rejects scaleToFill for uniform-only assets', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].render.uniMode = 'scaleToFill'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /scaleToFill/i)
|
||||
})
|
||||
|
||||
test('rejects unknown asset classes', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].assetClass = 'mystery'
|
||||
assert.throws(() => validateManifest(manifest, workspace), /assetClass/i)
|
||||
})
|
||||
|
||||
test('rejects assets without consumers', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0].consumers = []
|
||||
assert.throws(() => validateManifest(manifest, workspace), /consumers/i)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validateManifest } from '../scripts/manifest-v2.mjs'
|
||||
|
||||
const testDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const pipelineDirectory = path.resolve(testDirectory, '..')
|
||||
const workspace = path.resolve(pipelineDirectory, '..')
|
||||
const manifestPath = path.join(pipelineDirectory, 'manifests', 'a01-scroll-skins-v3.json')
|
||||
|
||||
test('declares the approved A01 scroll-skin family', async () => {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
validateManifest(manifest, workspace)
|
||||
|
||||
assert.equal(manifest.assets.length, 4)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ id }) => id),
|
||||
['a01-scroll-primary-v3', 'a01-scroll-secondary-v3', 'a01-scroll-toast-v3', 'a01-scroll-dialog-v3']
|
||||
)
|
||||
assert.deepEqual(
|
||||
manifest.assets.map(({ outputPixels }) => [outputPixels.width, outputPixels.height]),
|
||||
[[1866, 276], [1866, 300], [1770, 246], [1860, 1560]]
|
||||
)
|
||||
assert.equal(manifest.assets[2].assetClass, 'nine-slice')
|
||||
assert.equal(manifest.assets[2].render.scalePolicy, 'nine-slice')
|
||||
assert.equal(manifest.assets[3].render.uniMode, 'aspectFit')
|
||||
assert(manifest.assets.every(({ consumers }) => consumers.includes('pages/auth/a01-entry.vue')))
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from asset_quality import analyze_asset
|
||||
|
||||
|
||||
class AssetQualityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def spec(self):
|
||||
return {
|
||||
"id": "test-button",
|
||||
"outputPixels": {"width": 32, "height": 16},
|
||||
"alpha": {"required": True, "transparentOuterPadding": 2, "cornerMaxAlpha": 0},
|
||||
"edge": {
|
||||
"forbidChromaResidue": True,
|
||||
"forbidLightFringe": True,
|
||||
"premultipliedAlphaCheck": True,
|
||||
},
|
||||
"quality": {"maxBytes": 10000, "colorSpace": "sRGB"},
|
||||
}
|
||||
|
||||
def clean_image(self):
|
||||
image = Image.new("RGBA", (32, 16), (0, 0, 0, 0))
|
||||
for y in range(2, 14):
|
||||
for x in range(2, 30):
|
||||
image.putpixel((x, y), (150, 20, 12, 255))
|
||||
return image
|
||||
|
||||
def save(self, image, name="asset.png"):
|
||||
path = self.root / name
|
||||
image.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
def error_text(self, report):
|
||||
return " ".join(report["errors"])
|
||||
|
||||
def test_accepts_clean_rgba_asset(self):
|
||||
report = analyze_asset(self.save(self.clean_image()), self.spec())
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual(32, report["width"])
|
||||
self.assertEqual(16, report["height"])
|
||||
|
||||
def test_accepts_indexed_png_with_real_transparency(self):
|
||||
indexed = self.clean_image().quantize(
|
||||
colors=16,
|
||||
method=Image.Quantize.FASTOCTREE,
|
||||
dither=Image.Dither.NONE,
|
||||
)
|
||||
report = analyze_asset(self.save(indexed), self.spec())
|
||||
self.assertEqual([], report["errors"])
|
||||
self.assertEqual("P", report["mode"])
|
||||
|
||||
def test_rejects_wrong_dimensions(self):
|
||||
report = analyze_asset(self.save(Image.new("RGBA", (31, 16), (0, 0, 0, 0))), self.spec())
|
||||
self.assertIn("dimensions", self.error_text(report))
|
||||
|
||||
def test_rejects_opaque_outer_padding(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (120, 30, 20, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("outer padding", self.error_text(report))
|
||||
|
||||
def test_rejects_visible_green_residue(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((16, 8), (0, 255, 0, 255))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("chroma residue", self.error_text(report))
|
||||
|
||||
def test_rejects_light_partially_transparent_fringe(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((2, 8), (250, 250, 250, 128))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("light fringe", self.error_text(report))
|
||||
|
||||
def test_rejects_hidden_rgb_in_fully_transparent_pixels(self):
|
||||
image = self.clean_image()
|
||||
image.putpixel((0, 0), (255, 255, 255, 0))
|
||||
report = analyze_asset(self.save(image), self.spec())
|
||||
self.assertIn("transparent RGB", self.error_text(report))
|
||||
|
||||
def test_rejects_file_over_max_bytes(self):
|
||||
spec = self.spec()
|
||||
spec["quality"]["maxBytes"] = 8
|
||||
report = analyze_asset(self.save(self.clean_image()), spec)
|
||||
self.assertIn("maxBytes", self.error_text(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_g01_backgrounds import process_chroma_image, process_opaque_image, save_png
|
||||
|
||||
|
||||
class G01BackgroundBuilderTests(unittest.TestCase):
|
||||
def config(self):
|
||||
return {
|
||||
"dominanceStart": 30,
|
||||
"dominanceEnd": 220,
|
||||
"despillAllowance": 10,
|
||||
}
|
||||
|
||||
def test_pure_green_becomes_clean_transparency(self):
|
||||
source = Image.new("RGB", (2, 1), (0, 255, 0))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
|
||||
def test_warm_artwork_remains_opaque(self):
|
||||
source = Image.new("RGB", (1, 1), (210, 190, 150))
|
||||
output = process_chroma_image(source, self.config())
|
||||
self.assertEqual((210, 190, 150, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_antialiased_green_edge_is_translucent_and_despilled(self):
|
||||
source = Image.new("RGB", (1, 1), (100, 200, 90))
|
||||
output = process_chroma_image(source, self.config())
|
||||
red, green, blue, alpha = output.getpixel((0, 0))
|
||||
self.assertGreater(alpha, 0)
|
||||
self.assertLess(alpha, 255)
|
||||
self.assertLessEqual(green, max(red, blue) + 10)
|
||||
|
||||
def test_opaque_master_is_normalized_to_rgba_without_transparency(self):
|
||||
source = Image.new("RGB", (1, 1), (240, 235, 220))
|
||||
output = process_opaque_image(source)
|
||||
self.assertEqual("RGBA", output.mode)
|
||||
self.assertEqual((240, 235, 220, 255), output.getpixel((0, 0)))
|
||||
|
||||
def test_saved_png_declares_srgb_color_profile(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "asset.png"
|
||||
save_png(Image.new("RGBA", (1, 1), (240, 235, 220, 255)), path)
|
||||
with Image.open(path) as opened:
|
||||
self.assertIn("srgb", opened.info)
|
||||
|
||||
def test_saved_png_bytes_are_deterministic(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
first = Path(directory) / "first.png"
|
||||
second = Path(directory) / "second.png"
|
||||
image = Image.new("RGBA", (2, 2), (240, 235, 220, 255))
|
||||
save_png(image, first)
|
||||
save_png(image, second)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_scroll_skins import build_asset, remove_chroma_background, sanitize_output_edges, stretch_safe_center
|
||||
|
||||
|
||||
class BuildScrollSkinsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def test_chroma_background_becomes_transparent_black(self):
|
||||
image = Image.new("RGB", (4, 2), (0, 255, 0))
|
||||
image.putpixel((1, 0), (180, 30, 20))
|
||||
|
||||
cleaned = remove_chroma_background(image, (0, 255, 0), tolerance=80)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((180, 30, 20, 255), cleaned.getpixel((1, 0)))
|
||||
|
||||
def test_safe_center_stretch_preserves_caps_and_exact_size(self):
|
||||
image = Image.new("RGBA", (30, 10), (150, 20, 10, 255))
|
||||
for y in range(10):
|
||||
for x in range(5):
|
||||
image.putpixel((x, y), (220, 170, 40, 255))
|
||||
image.putpixel((29 - x, y), (220, 170, 40, 255))
|
||||
|
||||
output = stretch_safe_center(
|
||||
image,
|
||||
output_size=(60, 20),
|
||||
cap_width=5,
|
||||
padding=2,
|
||||
)
|
||||
|
||||
self.assertEqual((60, 20), output.size)
|
||||
self.assertEqual((0, 0, 0, 0), output.getpixel((0, 0)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((3, 10)))
|
||||
self.assertEqual((150, 20, 10, 255), output.getpixel((30, 10)))
|
||||
self.assertEqual((220, 170, 40, 255), output.getpixel((56, 10)))
|
||||
|
||||
def test_sanitizes_only_forbidden_edge_artifacts(self):
|
||||
image = Image.new("RGBA", (3, 1), (248, 240, 220, 255))
|
||||
image.putpixel((0, 0), (0, 255, 0, 128))
|
||||
image.putpixel((1, 0), (250, 250, 250, 96))
|
||||
|
||||
cleaned = sanitize_output_edges(image)
|
||||
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), cleaned.getpixel((1, 0)))
|
||||
self.assertEqual((248, 240, 220, 255), cleaned.getpixel((2, 0)))
|
||||
|
||||
def test_build_asset_honors_declared_palette_size(self):
|
||||
source = Image.new("RGB", (32, 16), (0, 255, 0))
|
||||
for y in range(1, 15):
|
||||
for x in range(1, 31):
|
||||
source.putpixel((x, y), (120 + (x % 30) * 4, 20 + y, 10))
|
||||
source_path = self.root / "source.png"
|
||||
output_path = self.root / "output.png"
|
||||
source.save(source_path)
|
||||
asset = {
|
||||
"id": "palette-test",
|
||||
"source": "source.png",
|
||||
"output": "output.png",
|
||||
"outputPixels": {"width": 60, "height": 30},
|
||||
"alpha": {"transparentOuterPadding": 1},
|
||||
"processing": {
|
||||
"capWidth": 4,
|
||||
"keyColor": "#00FF00",
|
||||
"keyTolerance": 80,
|
||||
"paletteColors": 8,
|
||||
},
|
||||
}
|
||||
|
||||
build_asset(asset, self.root)
|
||||
|
||||
with Image.open(output_path) as output:
|
||||
visible_colors = {
|
||||
pixel[:3] for pixel in output.convert("RGBA").get_flattened_data() if pixel[3] > 0
|
||||
}
|
||||
self.assertLessEqual(len(visible_colors), 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from rebuild_a01_buttons import rebuild_button
|
||||
|
||||
|
||||
class RebuildA01ButtonTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
|
||||
def make_source(self):
|
||||
image = Image.new("RGBA", (60, 30), (0, 255, 0, 255))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((4, 4, 55, 25), fill=(150, 20, 12, 255))
|
||||
draw.rectangle((4, 4, 13, 25), fill=(210, 160, 40, 255))
|
||||
draw.rectangle((46, 4, 55, 25), fill=(210, 160, 40, 255))
|
||||
return image
|
||||
|
||||
def test_rebuilds_three_slice_into_clean_transparent_canvas(self):
|
||||
source = self.root / "source.png"
|
||||
output = self.root / "output.png"
|
||||
self.make_source().save(source)
|
||||
|
||||
rebuild_button(source, output, output_size=(180, 60), trim=4, padding=6, cap_width=10)
|
||||
|
||||
with Image.open(output) as image:
|
||||
self.assertEqual("RGBA", image.mode)
|
||||
self.assertEqual((180, 60), image.size)
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((0, 0)))
|
||||
self.assertEqual((0, 0, 0, 0), image.getpixel((179, 59)))
|
||||
visible_pixels = [pixel for pixel in image.get_flattened_data() if pixel[3] > 8]
|
||||
self.assertFalse(
|
||||
any(green > 120 and green - max(red, blue) > 80 for red, green, blue, green_alpha in visible_pixels)
|
||||
)
|
||||
self.assertGreater(image.getpixel((7, 30))[0], image.getpixel((90, 30))[0])
|
||||
self.assertGreater(image.getpixel((172, 30))[0], image.getpixel((90, 30))[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user