509 lines
26 KiB
Python
509 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Build BP-001 Moses v05 as a bespoke Legendary card."""
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from xml.sax.saxutils import escape
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
REPO = next(p for p in ROOT.parents if (p / "docs/card-layer-pipeline.md").is_file())
|
|
sys.path.insert(0, str(REPO / "tools/card-production"))
|
|
|
|
from card_workspace import refresh, update_index
|
|
from finish_masks import RECIPE_PATH, RECIPE_SHA256, load_recipe, prepare_illustration
|
|
|
|
|
|
CANVAS = (2000, 2800)
|
|
PRINTINGS = ("normal", "boundless", "borderless", "textless")
|
|
RESOLUTIONS = (("low", 500), ("med", 1000), ("high", 2000))
|
|
PROOF = ROOT / "review/vertical-text-prototype-v7"
|
|
|
|
|
|
def digest(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def save_json(path: Path, value: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(value, indent=2) + "\n")
|
|
|
|
|
|
def check(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
|
|
def alpha(image: Image.Image) -> np.ndarray:
|
|
check("A" in image.getbands(), "Component must carry alpha")
|
|
return np.asarray(image.getchannel("A"), dtype=np.uint8)
|
|
|
|
|
|
def split_overlay_svg(source: Path, frame_output: Path, backing_output: Path) -> None:
|
|
"""Split the selected proof's combined SVG without redrawing its geometry."""
|
|
tree = ET.parse(source)
|
|
root = tree.getroot()
|
|
children = list(root)
|
|
check(children and children[0].tag.endswith("defs"), "Selected overlay SVG lacks defs")
|
|
|
|
def write(destination: Path, want_frame: bool) -> None:
|
|
result = ET.Element(root.tag, root.attrib)
|
|
result.append(deepcopy(children[0]))
|
|
for child in children[1:]:
|
|
is_frame = child.attrib.get("id") == "legendary-frame"
|
|
if is_frame == want_frame:
|
|
result.append(deepcopy(child))
|
|
ET.register_namespace("", "http://www.w3.org/2000/svg")
|
|
ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
ET.ElementTree(result).write(destination, encoding="unicode")
|
|
|
|
write(frame_output, True)
|
|
write(backing_output, False)
|
|
|
|
|
|
def main() -> None:
|
|
card_path = ROOT / "card.json"
|
|
manifest_path = ROOT / "manifest.json"
|
|
card = json.loads(card_path.read_text())
|
|
manifest = json.loads(manifest_path.read_text())
|
|
art_path = ROOT / "source/art-master.png"
|
|
art = Image.open(art_path).convert("RGBA")
|
|
check(art.size == CANVAS and art.getchannel("A").getextrema() == (255, 255), "Selected art must be opaque 2000x2800")
|
|
check(digest(art_path) == card["artSHA256"], "Selected art hash changed")
|
|
|
|
selected = {
|
|
"normal": PROOF / "composed-preview-2000.png",
|
|
"overlaySvg": PROOF / "border-backing-preview.svg",
|
|
"foreground": PROOF / "foreground-curtain.png",
|
|
"foregroundSvg": PROOF / "foreground-curtain.svg",
|
|
"text": PROOF / "text-preview.png",
|
|
"textSvg": PROOF / "text-preview.svg",
|
|
"validation": PROOF / "preview-validation.json",
|
|
}
|
|
for path in selected.values():
|
|
check(path.is_file(), f"Missing selected v7 proof input: {path}")
|
|
proof_validation = json.loads(selected["validation"].read_text())
|
|
check(proof_validation["status"] == "passed", "Selected v7 proof did not pass")
|
|
check(proof_validation["structuralChecks"]["curtainTextIntersectionPixels"] == 0, "Selected foreground intersects text")
|
|
check(proof_validation["structuralChecks"]["glyphPixelsOutsideOpaqueBacking"] == 0, "Selected text escapes backing")
|
|
|
|
fonts = REPO / "fonts"
|
|
font_manifest_path = fonts / "manifest.json"
|
|
font_manifest = json.loads(font_manifest_path.read_text())
|
|
title_font = fonts / "P052-Bold.otf"
|
|
body_font = fonts / "SanctificationP052-Medium.otf"
|
|
for font in (title_font, body_font):
|
|
check(digest(font) == font_manifest["files"][font.name]["sha256"], f"Font hash mismatch: {font.name}")
|
|
|
|
review = ROOT / "review"
|
|
layers = ROOT / "source/layers"
|
|
text_dir = ROOT / "source/text"
|
|
foreground_dir = ROOT / "source/foreground"
|
|
material_dir = ROOT / "source/material"
|
|
for directory in (review, layers, text_dir, foreground_dir, material_dir):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
font_cache = review / "font-cache"
|
|
font_cache.mkdir(exist_ok=True)
|
|
fonts_conf = review / "fonts.conf"
|
|
fonts_conf.write_text(f"<fontconfig><dir>{fonts}</dir><cachedir>{font_cache}</cachedir></fontconfig>")
|
|
env = dict(os.environ, FONTCONFIG_FILE=str(fonts_conf))
|
|
|
|
def ink(*args: object) -> str:
|
|
return subprocess.check_output(["inkscape", *map(str, args)], env=env, text=True, stderr=subprocess.PIPE).strip()
|
|
|
|
def render(source: Path, destination: Path) -> None:
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
ink(source, "--export-type=png", f"--export-filename={destination}", "--export-width=2000", "--export-height=2800")
|
|
|
|
resolved_title = Path(subprocess.check_output(["fc-match", "-f", "%{file}", "P052:style=Bold"], env=env, text=True).strip()).resolve()
|
|
resolved_body = Path(subprocess.check_output(["fc-match", "-f", "%{file}", "Sanctification P052:style=Medium"], env=env, text=True).strip()).resolve()
|
|
check(resolved_title == title_font.resolve(), "P052 Bold fallback detected")
|
|
check(resolved_body == body_font.resolve(), "P052 Medium fallback detected")
|
|
|
|
# Freeze the selected proof components as production inputs.
|
|
shutil.copy2(selected["normal"], ROOT / "source/selected-normal-2000.png")
|
|
shutil.copy2(selected["foreground"], foreground_dir / "foreground-2000.png")
|
|
shutil.copy2(selected["foregroundSvg"], foreground_dir / "selected-foreground.svg")
|
|
shutil.copy2(selected["text"], text_dir / "text-2000.png")
|
|
shutil.copy2(selected["textSvg"], text_dir / "selected-text.svg")
|
|
shutil.copy2(selected["overlaySvg"], layers / "selected-overlay.svg")
|
|
|
|
frame_svg = layers / "frame.svg"
|
|
backing_svg = layers / "backing.svg"
|
|
split_overlay_svg(selected["overlaySvg"], frame_svg, backing_svg)
|
|
render(frame_svg, layers / "frame-2000.png")
|
|
render(backing_svg, layers / "backing-2000.png")
|
|
|
|
frame = Image.open(layers / "frame-2000.png").convert("RGBA")
|
|
backing = Image.open(layers / "backing-2000.png").convert("RGBA")
|
|
foreground = Image.open(foreground_dir / "foreground-2000.png").convert("RGBA")
|
|
text = Image.open(text_dir / "text-2000.png").convert("RGBA")
|
|
for name, component in (("frame", frame), ("backing", backing), ("foreground", foreground), ("text", text)):
|
|
check(component.size == CANVAS, f"{name} is not registered to the master canvas")
|
|
|
|
# The source curtain sits in front of the outer left rail, but below the backing.
|
|
rail_mask = Image.new("L", CANVAS, 0)
|
|
ImageDraw.Draw(rail_mask).polygon([(0, 0), (80, 0), (80, 2646), (0, 2640)], fill=255)
|
|
rail = art.copy()
|
|
rail.putalpha(rail_mask)
|
|
rail.save(foreground_dir / "curtain-over-rail-2000.png")
|
|
|
|
# Textless keeps the scene's depth at the top-left corner: the tent roof
|
|
# and cloud crown occlude the perimeter rail until it reaches open sky.
|
|
# The selected foreground already carries that approved source contour;
|
|
# crop its alpha to the upper crown so the sleeve is not duplicated here.
|
|
crown_alpha_image = foreground.getchannel("A")
|
|
crown_alpha_values = np.asarray(crown_alpha_image).copy()
|
|
crown_alpha_values[600:, :] = 0
|
|
cloud_crown = foreground.copy()
|
|
cloud_crown.putalpha(Image.fromarray(crown_alpha_values, "L"))
|
|
cloud_crown.save(foreground_dir / "cloud-crown-over-frame-2000.png")
|
|
textless_occlusion = Image.alpha_composite(rail, cloud_crown)
|
|
textless_occlusion.save(foreground_dir / "textless-frame-occlusion-2000.png")
|
|
|
|
combined_overlay = Image.alpha_composite(frame, backing)
|
|
combined_overlay.save(layers / "overlay-normal-2000.png")
|
|
backing.save(layers / "backing-borderless-2000.png")
|
|
frame.save(layers / "frame-textless-2000.png")
|
|
|
|
frame_a = alpha(frame).astype(np.float32) / 255.0
|
|
backing_a = alpha(backing).astype(np.float32) / 255.0
|
|
foreground_a = alpha(foreground).astype(np.float32) / 255.0
|
|
rail_a = alpha(rail).astype(np.float32) / 255.0
|
|
crown_a = alpha(cloud_crown).astype(np.float32) / 255.0
|
|
text_a = alpha(text).astype(np.float32) / 255.0
|
|
check(not np.any((text_a > 0) & (foreground_a > 0)), "Production foreground intersects text")
|
|
check(np.all(alpha(backing)[text_a > 0] == 255), "Production text escapes opaque backing")
|
|
|
|
selected_normal = Image.open(ROOT / "source/selected-normal-2000.png").convert("RGBA")
|
|
check(selected_normal.size == CANVAS and selected_normal.getchannel("A").getextrema() == (255, 255), "Selected Normal is invalid")
|
|
|
|
# Reconstruct the selected order as a diagnostic. The selected PNG remains
|
|
# authoritative because the approved proof pins inherited antialias pixels.
|
|
reconstructed = Image.alpha_composite(art, frame)
|
|
reconstructed = Image.alpha_composite(reconstructed, rail)
|
|
reconstructed = Image.alpha_composite(reconstructed, backing)
|
|
reconstructed = Image.alpha_composite(reconstructed, foreground)
|
|
reconstructed = Image.alpha_composite(reconstructed, text)
|
|
reconstructed.save(review / "normal-reconstructed.png")
|
|
delta = np.abs(np.asarray(selected_normal, dtype=np.int16)[:, :, :3] - np.asarray(reconstructed, dtype=np.int16)[:, :, :3])
|
|
changed = np.any(delta > 0, axis=2)
|
|
|
|
faces = {
|
|
"normal": selected_normal,
|
|
"boundless": art.copy(),
|
|
"borderless": Image.alpha_composite(Image.alpha_composite(Image.alpha_composite(art, backing), foreground), text),
|
|
"textless": Image.alpha_composite(Image.alpha_composite(art, frame), textless_occlusion),
|
|
}
|
|
check(np.array_equal(np.asarray(faces["boundless"]), np.asarray(art)), "Boundless must equal selected art")
|
|
expected_textless = Image.alpha_composite(Image.alpha_composite(art, frame), textless_occlusion)
|
|
check(np.array_equal(np.asarray(faces["textless"]), np.asarray(expected_textless)), "Textless component error")
|
|
crown_opaque = crown_a == 1.0
|
|
check(np.array_equal(np.asarray(faces["textless"])[crown_opaque], np.asarray(cloud_crown)[crown_opaque]), "Tent/cloud crown does not fully occlude the Textless frame")
|
|
check(np.any(frame_a[:100, 1200:] > 0), "Textless top rail no longer resumes over open sky")
|
|
|
|
# Expand and soften actual glyph alpha for the runtime readability mask.
|
|
protected_text = text.getchannel("A").filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8))
|
|
check(int(np.min(np.asarray(protected_text)[text_a >= 0.5])) >= 216, "Text protection is too weak over rendered glyphs")
|
|
|
|
# Analyze the illustration and the source-registered adapted foreground once.
|
|
base_analysis = prepare_illustration(art)
|
|
adapted_plate = Image.alpha_composite(art, foreground)
|
|
adapted_analysis = prepare_illustration(adapted_plate)
|
|
adapted_plate.save(material_dir / "foreground-analysis-plate.png")
|
|
Image.fromarray(base_analysis.raw_ridges).save(material_dir / "base-raw-ridges.png")
|
|
Image.fromarray(np.rint(base_analysis.pane_weights * 255).astype(np.uint8)).save(material_dir / "base-pane-coverage.png")
|
|
Image.fromarray(adapted_analysis.raw_ridges).save(material_dir / "foreground-raw-ridges.png")
|
|
Image.fromarray(np.rint(adapted_analysis.pane_weights * 255).astype(np.uint8)).save(material_dir / "foreground-pane-coverage.png")
|
|
|
|
def coverage(analysis) -> np.ndarray:
|
|
return analysis.pane_weights * (1.0 - analysis.raw_ridges.astype(np.float32) / 255.0)
|
|
|
|
base_coverage = coverage(base_analysis)
|
|
adapted_coverage = coverage(adapted_analysis)
|
|
|
|
def suppress(values: np.ndarray, component_alpha: np.ndarray) -> np.ndarray:
|
|
return values * (1.0 - component_alpha)
|
|
|
|
def restore(values: np.ndarray, component_alpha: np.ndarray, component_coverage: np.ndarray) -> np.ndarray:
|
|
return values * (1.0 - component_alpha) + component_coverage * component_alpha
|
|
|
|
finish = {}
|
|
# Resolve each printing in its actual front-to-back order.
|
|
values = suppress(base_coverage, frame_a)
|
|
values = restore(values, rail_a, base_coverage)
|
|
values = suppress(values, backing_a)
|
|
values = restore(values, foreground_a, adapted_coverage)
|
|
finish["normal"] = suppress(values, text_a)
|
|
|
|
values = suppress(base_coverage, backing_a)
|
|
values = restore(values, foreground_a, adapted_coverage)
|
|
finish["borderless"] = suppress(values, text_a)
|
|
values = restore(suppress(base_coverage, frame_a), rail_a, base_coverage)
|
|
finish["textless"] = restore(values, crown_a, adapted_coverage)
|
|
finish["boundless"] = base_coverage
|
|
|
|
head = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" height="2800" viewBox="0 0 2000 2800">'
|
|
|
|
def image_node(editable: Path, source: Path) -> str:
|
|
return f'<image width="2000" height="2800" xlink:href="{escape(os.path.relpath(source, editable.parent))}"/>'
|
|
|
|
printing_components = {
|
|
"normal": ["base-art", "frame", "curtain-over-rail", "backing", "adapted-foreground", "text"],
|
|
"boundless": ["base-art"],
|
|
"borderless": ["base-art", "backing", "adapted-foreground", "text"],
|
|
"textless": ["base-art", "frame", "curtain-over-rail", "cloud-crown-over-frame"],
|
|
}
|
|
report = {
|
|
"status": "pending",
|
|
"cardId": card["cardId"],
|
|
"revision": manifest["revision"],
|
|
"finalCardApproval": None,
|
|
"assemblyAuthorization": "Okay. Let's finish the Moses card with the actual generating the prints/masks",
|
|
"selectedPrototype": {
|
|
"path": str(PROOF.relative_to(REPO)),
|
|
"normalSHA256": digest(selected["normal"]),
|
|
"validationSHA256": digest(selected["validation"]),
|
|
"authority": "v7 comparison PNG; inherited antialias pixels intentionally pinned by the selected proof",
|
|
},
|
|
"inputs": {
|
|
"art": {"path": str(art_path.relative_to(REPO)), "sha256": digest(art_path)},
|
|
"foreground": {"path": str((foreground_dir / "foreground-2000.png").relative_to(REPO)), "sha256": digest(foreground_dir / "foreground-2000.png")},
|
|
"text": {"path": str((text_dir / "text-2000.png").relative_to(REPO)), "sha256": digest(text_dir / "text-2000.png")},
|
|
"builderSHA256": digest(Path(__file__)),
|
|
},
|
|
"bespokeLayers": {
|
|
"template": "BP-001-moses-legendary-tent-textile-v1",
|
|
"canvas": list(CANVAS),
|
|
"compositionOrder": ["base-art", "frame", "curtain-over-rail", "backing", "adapted-cloud-and-sleeve", "text"],
|
|
"printingComponents": printing_components,
|
|
},
|
|
"typography": {
|
|
"sharedFontManifestSHA256": digest(font_manifest_path),
|
|
"title": {"font": "P052-Bold.otf", "weight": 700, "size": 142},
|
|
"verse": {"font": "SanctificationP052-Medium.otf", "weight": 500, "size": 66, "lines": [
|
|
"And there has not arisen", "a prophet since in Israel", "like Moses, whom the LORD", "knew face to face"
|
|
]},
|
|
"reference": {"font": "SanctificationP052-Medium.otf", "weight": 500, "size": 48, "text": card["referenceDisplay"]},
|
|
"proofChecks": proof_validation["typography"],
|
|
},
|
|
"textMask": {"expansionRadius": 12, "gaussianRadius": 8, "units": "master pixels"},
|
|
"finishRecipe": {
|
|
"id": load_recipe()["id"],
|
|
"sha256": RECIPE_SHA256,
|
|
"path": str(RECIPE_PATH.relative_to(REPO)),
|
|
"baseAnalysisCount": 1,
|
|
"adaptedForegroundAnalysisCount": 1,
|
|
},
|
|
"normalAuthorityCheck": {
|
|
"productionEqualsSelectedPrototype": True,
|
|
"selectedSHA256": digest(selected["normal"]),
|
|
"productionSHA256": None,
|
|
"reconstructionChangedPixels": int(np.count_nonzero(changed)),
|
|
"reconstructionMaximumChannelDelta": int(delta.max()),
|
|
},
|
|
"printings": {},
|
|
"tools": {"inkscape": ink("--version"), "pillow": Image.__version__, "numpy": np.__version__},
|
|
"visualReview": {"static": "pending user review", "movingLight": "not performed", "userApproval": "pending"},
|
|
}
|
|
|
|
source_paths = {
|
|
"normal": ROOT / "source/selected-normal-2000.png",
|
|
"boundless": art_path,
|
|
"borderless": None,
|
|
"textless": None,
|
|
}
|
|
for printing in PRINTINGS:
|
|
face = faces[printing]
|
|
check(face.getchannel("A").getextrema() == (255, 255), f"{printing} face is not opaque")
|
|
finish_master = Image.fromarray(np.rint(np.clip(finish[printing], 0.0, 1.0) * 255).astype(np.uint8), "L")
|
|
has_text = printing in ("normal", "borderless")
|
|
exports = []
|
|
for label, width in RESOLUTIONS:
|
|
dimensions = (width, width * 7 // 5)
|
|
folder = ROOT / label / printing
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
if printing == "normal" and width == 2000:
|
|
# The selected proof PNG is the comparison authority. Preserve
|
|
# its original RGB encoding as well as its pixels.
|
|
shutil.copy2(selected["normal"], folder / "card.png")
|
|
else:
|
|
output_face = face if width == 2000 else face.resize(dimensions, Image.Resampling.LANCZOS)
|
|
output_face.save(folder / "card.png")
|
|
output_finish = finish_master if width == 2000 else finish_master.resize(dimensions, Image.Resampling.BILINEAR)
|
|
output_finish.convert("RGBA").save(folder / "finish-mask.png")
|
|
if has_text:
|
|
output_text = protected_text if width == 2000 else protected_text.resize(dimensions, Image.Resampling.BILINEAR)
|
|
output_text.convert("RGBA").save(folder / "text-mask.png")
|
|
names = ["card.png", "finish-mask.png"] + (["text-mask.png"] if has_text else [])
|
|
exports.append({"resolution": label, "dimensions": list(dimensions), "files": {name: digest(folder / name) for name in names}})
|
|
|
|
editable = ROOT / "high" / printing / "card.svg"
|
|
if source_paths[printing] is not None:
|
|
body = image_node(editable, source_paths[printing])
|
|
else:
|
|
body = image_node(editable, art_path)
|
|
if printing == "borderless":
|
|
body += image_node(editable, layers / "backing-2000.png")
|
|
body += image_node(editable, foreground_dir / "foreground-2000.png")
|
|
body += image_node(editable, text_dir / "text-2000.png")
|
|
elif printing == "textless":
|
|
body += image_node(editable, layers / "frame-2000.png")
|
|
body += image_node(editable, foreground_dir / "textless-frame-occlusion-2000.png")
|
|
editable.write_text(head + body + "</svg>")
|
|
report["printings"][printing] = {
|
|
"components": printing_components[printing],
|
|
"textMask": has_text,
|
|
"finishCoverage": "base plus depth-resolved adapted foreground" if printing in ("normal", "borderless") else "base illustration with frame depth resolved" if printing == "textless" else "base illustration",
|
|
"exports": exports,
|
|
}
|
|
|
|
report["normalAuthorityCheck"]["productionSHA256"] = digest(ROOT / "high/normal/card.png")
|
|
check(report["normalAuthorityCheck"]["productionSHA256"] == report["normalAuthorityCheck"]["selectedSHA256"], "Normal export changed from selected v7 proof")
|
|
|
|
save_json(layers / "layout.json", {
|
|
"id": "BP-001-moses-legendary-tent-textile-v1",
|
|
"canvas": list(CANVAS),
|
|
"selectedProof": str(PROOF.relative_to(REPO)),
|
|
"titleBacking": "upper-right paper plaque beneath the source cloud crown",
|
|
"verseBacking": "shortened left tent textile with shallow pitched seam",
|
|
"depthOrder": ["base-art", "frame", "curtain-over-rail", "backings", "cloud-crown-and-complete-blue-red-sleeve", "text"],
|
|
"printingComponents": printing_components,
|
|
"foregroundScope": "Normal and Borderless restore the source cloud crown plus complete blue outer sleeve and red inner lining ending at the low V. Textless restores the tent/cloud crown over the top-left rail. The separate lower red shard stays behind the backing.",
|
|
})
|
|
save_json(ROOT / "source/typography-layout.json", {
|
|
"canvas": list(CANVAS),
|
|
"policy": "Bespoke Legendary coordinates; semantic four-line verse; actual ink centered in the reserved band.",
|
|
"titleAnchor": [1458, 244],
|
|
"verseAnchorX": 530,
|
|
"verseBaselines": [2189, 2299, 2409, 2519],
|
|
"referenceBaseline": 2640,
|
|
"fontSizes": {"title": 142, "verse": 66, "reference": 48},
|
|
"verseLines": report["typography"]["verse"]["lines"],
|
|
"reference": card["referenceDisplay"],
|
|
"measured": {
|
|
"verseInkUnion": proof_validation["typography"]["verseInkUnion"],
|
|
"upperFlourishToVerseInk": proof_validation["typography"]["upperFlourishToVerseInk"],
|
|
"verseInkToReferenceLineBox": proof_validation["typography"]["verseInkToReferenceLineBox"],
|
|
"centeringDifference": proof_validation["typography"]["centeringDifference"],
|
|
},
|
|
})
|
|
save_json(ROOT / "source/production-inputs.json", {
|
|
"selectedProof": str(PROOF.relative_to(REPO)),
|
|
"files": {name: {"path": str(path.relative_to(REPO)), "sha256": digest(path)} for name, path in selected.items()},
|
|
"fonts": {font.name: digest(font) for font in (title_font, body_font)},
|
|
"finishRecipeSHA256": RECIPE_SHA256,
|
|
})
|
|
|
|
# Review sheets.
|
|
label_font = ImageFont.truetype(str(title_font), 24)
|
|
for filename, item_name in (("printings-comparison.png", "card.png"), ("finish-masks-comparison.png", "finish-mask.png")):
|
|
sheet = Image.new("RGB", (1600, 610), "#11161c")
|
|
draw = ImageDraw.Draw(sheet)
|
|
for index, printing in enumerate(PRINTINGS):
|
|
draw.text((index * 400 + 14, 12), printing.title(), font=label_font, fill="#f4ead4")
|
|
image = Image.open(ROOT / "med" / printing / item_name).convert("RGB")
|
|
image.thumbnail((370, 518), Image.Resampling.LANCZOS if item_name == "card.png" else Image.Resampling.BILINEAR)
|
|
sheet.paste(image, (index * 400 + 14, 56))
|
|
sheet.save(review / filename)
|
|
|
|
checker = Image.new("RGBA", CANVAS, (30, 34, 40, 255))
|
|
checker_values = np.asarray(checker).copy()
|
|
for y in range(0, 2800, 100):
|
|
for x in range(0, 2000, 100):
|
|
if (x // 100 + y // 100) % 2:
|
|
checker_values[y:y + 100, x:x + 100, :3] = [56, 61, 69]
|
|
checker = Image.fromarray(checker_values, "RGBA")
|
|
items = [("Frame", frame), ("Backings", backing), ("Textless depth", textless_occlusion), ("Cloud + sleeve", foreground), ("Text", text)]
|
|
sheet = Image.new("RGB", (1500, 500), "#11161c")
|
|
draw = ImageDraw.Draw(sheet)
|
|
for index, (label, layer) in enumerate(items):
|
|
draw.text((index * 300 + 10, 8), label, font=label_font, fill="#f4ead4")
|
|
composed = checker.copy()
|
|
composed.alpha_composite(layer)
|
|
composed.thumbnail((280, 392), Image.Resampling.LANCZOS)
|
|
sheet.paste(composed.convert("RGB"), (index * 300 + 10, 50))
|
|
sheet.save(review / "layers-comparison.png")
|
|
|
|
detail = Image.new("RGB", (1500, 760), "#11161c")
|
|
draw = ImageDraw.Draw(detail)
|
|
for index, (label, path) in enumerate([
|
|
("Normal card", ROOT / "high/normal/card.png"),
|
|
("Normal finish", ROOT / "high/normal/finish-mask.png"),
|
|
("Text protection", ROOT / "high/normal/text-mask.png"),
|
|
]):
|
|
draw.text((index * 500 + 12, 12), label, font=label_font, fill="#f4ead4")
|
|
image = Image.open(path).convert("RGB").crop((0, 1500, 1200, 2800))
|
|
image.thumbnail((480, 690), Image.Resampling.LANCZOS)
|
|
detail.paste(image, (index * 500 + 10, 48))
|
|
detail.save(review / "textile-finish-detail.png")
|
|
|
|
report["status"] = "passed"
|
|
save_json(review / "build-validation.json", report)
|
|
save_json(review / "static-review.json", {
|
|
"status": "passed-structural-user-visual-review-pending",
|
|
"cardId": card["cardId"],
|
|
"revision": manifest["revision"],
|
|
"checks": {
|
|
"selectedNormalPreservedByteForByte": True,
|
|
"fourPrintingComposition": "passed",
|
|
"layerRegistration": "passed",
|
|
"opaqueFaces": "passed",
|
|
"textOnOpaqueBacking": "passed",
|
|
"foregroundTextIntersectionPixels": 0,
|
|
"depthResolvedFinishCoverage": "passed",
|
|
"textlessTentCloudOccludesTopLeftRail": "passed",
|
|
"textlessTopRailResumesOverOpenSky": "passed",
|
|
"lowMedHighExports": "passed",
|
|
},
|
|
"reviewAssets": ["printings-comparison.png", "layers-comparison.png", "finish-masks-comparison.png", "textile-finish-detail.png"],
|
|
"staticVisualAssessment": "Pending user review of the four production printings and masks.",
|
|
"movingLightReview": "Not performed.",
|
|
"finalCardApproval": None,
|
|
})
|
|
|
|
card.update({
|
|
"artApproval": {"by": "user", "scope": "v05 source art and selected v7 Legendary Normal composition"},
|
|
"artStatus": "Selected v05 illustration approved for production assembly",
|
|
"layoutStatus": "Bespoke Legendary tent-textile production candidate assembled; four printings and masks await user review.",
|
|
"template": "BP-001-moses-legendary-tent-textile-v1",
|
|
"printingComponents": printing_components,
|
|
"typographyCandidate": {
|
|
"titleFontSize": 142,
|
|
"verseFontSize": 66,
|
|
"verseBaselineGap": 110,
|
|
"referenceFontSize": 48,
|
|
"lineBreaks": report["typography"]["verse"]["lines"],
|
|
},
|
|
"finishRecipe": "raw-ridges-v1",
|
|
"assemblyAuthorization": {"by": "user", "note": report["assemblyAuthorization"]},
|
|
})
|
|
save_json(card_path, card)
|
|
manifest["stage"] = "assembly-review"
|
|
manifest["approval"] = None
|
|
save_json(manifest_path, manifest)
|
|
profile_path = ROOT.parents[1] / "card.json"
|
|
profile = json.loads(profile_path.read_text())
|
|
profile.update({"stage": "assembly-review", "selectedRevision": manifest["revision"], "approval": None})
|
|
save_json(profile_path, profile)
|
|
refresh(ROOT)
|
|
update_index()
|
|
print(f"{card['cardId']} {manifest['revision']}: four Legendary printings and production masks exported; structural validation passed.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|