first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose or normalize a Codex pet spritesheet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT
|
||||
ROW_SPECS = [
|
||||
("idle", 0, 6),
|
||||
("running-right", 1, 8),
|
||||
("running-left", 2, 8),
|
||||
("waving", 3, 4),
|
||||
("jumping", 4, 5),
|
||||
("failed", 5, 8),
|
||||
("waiting", 6, 6),
|
||||
("running", 7, 6),
|
||||
("review", 8, 6),
|
||||
]
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def image_files(path: Path) -> list[Path]:
|
||||
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]:
|
||||
candidates = [
|
||||
root / state,
|
||||
root / f"row-{row_index}",
|
||||
root / f"row{row_index}",
|
||||
root / f"{row_index}-{state}",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir():
|
||||
files = image_files(candidate)
|
||||
if files:
|
||||
return files
|
||||
globs = [
|
||||
f"{state}_*",
|
||||
f"{state}-*",
|
||||
f"row{row_index}_*",
|
||||
f"row-{row_index}-*",
|
||||
]
|
||||
files: list[Path] = []
|
||||
for pattern in globs:
|
||||
files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES)
|
||||
return sorted(set(files))
|
||||
|
||||
|
||||
def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None:
|
||||
frame = source.convert("RGBA")
|
||||
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
|
||||
left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2
|
||||
top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2
|
||||
atlas.alpha_composite(frame, (left, top))
|
||||
|
||||
|
||||
def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image:
|
||||
with Image.open(path) as opened:
|
||||
source = opened.convert("RGBA")
|
||||
if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
if not resize_source:
|
||||
raise SystemExit(
|
||||
f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}"
|
||||
)
|
||||
source_ratio = source.width / source.height
|
||||
if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02:
|
||||
raise SystemExit(
|
||||
"refusing to resize source atlas because its aspect ratio does not match "
|
||||
f"the Codex atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. "
|
||||
"Generate exact atlas dimensions or use --frames-root."
|
||||
)
|
||||
source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS)
|
||||
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for _state, row, frame_count in ROW_SPECS:
|
||||
for column in range(frame_count):
|
||||
left = column * CELL_WIDTH
|
||||
top = row * CELL_HEIGHT
|
||||
cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
atlas.alpha_composite(cell, (left, top))
|
||||
return atlas
|
||||
|
||||
|
||||
def compose_from_frames(root: Path) -> Image.Image:
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for state, row, frame_count in ROW_SPECS:
|
||||
files = find_row_frames(root, state, row)
|
||||
if len(files) < frame_count:
|
||||
raise SystemExit(
|
||||
f"{state} row needs {frame_count} frames, found {len(files)} under {root}"
|
||||
)
|
||||
for column, frame_path in enumerate(files[:frame_count]):
|
||||
with Image.open(frame_path) as frame:
|
||||
paste_centered(atlas, frame, row, column)
|
||||
return atlas
|
||||
|
||||
|
||||
def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(output)
|
||||
if webp_output is not None:
|
||||
webp_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--source-atlas")
|
||||
source.add_argument("--frames-root")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--webp-output")
|
||||
parser.add_argument(
|
||||
"--resize-source",
|
||||
action="store_true",
|
||||
help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.source_atlas:
|
||||
atlas = compose_from_source_atlas(
|
||||
Path(args.source_atlas).expanduser().resolve(), args.resize_source
|
||||
)
|
||||
else:
|
||||
atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve())
|
||||
|
||||
save_outputs(
|
||||
atlas,
|
||||
Path(args.output).expanduser().resolve(),
|
||||
Path(args.webp_output).expanduser().resolve() if args.webp_output else None,
|
||||
)
|
||||
print(f"wrote {Path(args.output).expanduser().resolve()}")
|
||||
if args.webp_output:
|
||||
print(f"wrote {Path(args.webp_output).expanduser().resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Conditionally derive running-left by mirroring the approved running-right strip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") == job_id:
|
||||
return job
|
||||
raise SystemExit(f"unknown job id: {job_id}")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
with Image.open(path) as image:
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"mode": image.mode,
|
||||
"format": image.format,
|
||||
}
|
||||
|
||||
|
||||
def manifest_relative(path: Path, run_dir: Path) -> str:
|
||||
return str(path.resolve().relative_to(run_dir.resolve()))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument(
|
||||
"--confirm-appropriate-mirror",
|
||||
action="store_true",
|
||||
help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--decision-note",
|
||||
required=True,
|
||||
help="Short note explaining why mirroring is acceptable for this pet.",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.confirm_appropriate_mirror:
|
||||
raise SystemExit("refusing to mirror without --confirm-appropriate-mirror")
|
||||
if not args.decision_note.strip():
|
||||
raise SystemExit("--decision-note must explain why mirroring is appropriate")
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_manifest(run_dir)
|
||||
right_job = find_job(manifest, "running-right")
|
||||
left_job = find_job(manifest, "running-left")
|
||||
|
||||
if right_job.get("status") != "complete":
|
||||
raise SystemExit("running-right must be complete before deriving running-left")
|
||||
mirror_policy = left_job.get("mirror_policy")
|
||||
if not isinstance(mirror_policy, dict) or mirror_policy.get("may_derive_from") != "running-right":
|
||||
raise SystemExit("running-left is not configured for conditional mirroring")
|
||||
|
||||
source = run_dir / "decoded" / "running-right.png"
|
||||
output = run_dir / "decoded" / "running-left.png"
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"running-right decoded strip not found: {source}")
|
||||
if output.exists() and not args.force:
|
||||
raise SystemExit(f"{output} already exists; pass --force to replace it")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as image:
|
||||
mirrored = ImageOps.mirror(image.convert("RGBA"))
|
||||
mirrored.save(output)
|
||||
|
||||
left_job["status"] = "complete"
|
||||
left_job["source_path"] = manifest_relative(source, run_dir)
|
||||
left_job["source_provenance"] = "deterministic-mirror"
|
||||
left_job["derived_from"] = "running-right"
|
||||
left_job["source_sha256"] = file_sha256(source)
|
||||
left_job["output_sha256"] = file_sha256(output)
|
||||
left_job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
left_job["metadata"] = image_metadata(output)
|
||||
left_job["mirror_decision"] = {
|
||||
"approved": True,
|
||||
"approved_at": left_job["completed_at"],
|
||||
"note": args.decision_note.strip(),
|
||||
}
|
||||
for key in [
|
||||
"last_error",
|
||||
"secondary_fallback",
|
||||
"synthetic_test_source",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
left_job.pop(key, None)
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"job_id": "running-left",
|
||||
"derived_from": "running-right",
|
||||
"output": str(output),
|
||||
"decision_note": args.decision_note.strip(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract generated horizontal row strips into 192x208 sprite frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ROW_FRAME_COUNTS = {
|
||||
"idle": 6,
|
||||
"running-right": 8,
|
||||
"running-left": 8,
|
||||
"waving": 4,
|
||||
"jumping": 5,
|
||||
"failed": 8,
|
||||
"waiting": 6,
|
||||
"running": 6,
|
||||
"review": 6,
|
||||
}
|
||||
|
||||
|
||||
def parse_states(raw: str) -> list[str]:
|
||||
if raw.strip().lower() == "all":
|
||||
return list(ROW_FRAME_COUNTS)
|
||||
states = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
|
||||
return states
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]:
|
||||
if override:
|
||||
return parse_hex_color(override)
|
||||
request_path = decoded_dir.parent / "pet_request.json"
|
||||
if request_path.is_file():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
chroma_key = request.get("chroma_key")
|
||||
if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str):
|
||||
return parse_hex_color(chroma_key["hex"])
|
||||
return parse_hex_color("#00FF00")
|
||||
|
||||
|
||||
def color_distance(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
key: tuple[int, int, int],
|
||||
) -> float:
|
||||
return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)
|
||||
|
||||
|
||||
def remove_chroma_background(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
) -> Image.Image:
|
||||
rgba = image.convert("RGBA")
|
||||
pixels = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if color_distance(red, green, blue, chroma_key) <= threshold:
|
||||
pixels[x, y] = (red, green, blue, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
def fit_to_cell(image: Image.Image) -> Image.Image:
|
||||
bbox = image.getbbox()
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
if bbox is None:
|
||||
return target
|
||||
|
||||
sprite = image.crop(bbox)
|
||||
max_width = CELL_WIDTH - 10
|
||||
max_height = CELL_HEIGHT - 10
|
||||
scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = (CELL_WIDTH - sprite.width) // 2
|
||||
top = (CELL_HEIGHT - sprite.height) // 2
|
||||
target.alpha_composite(sprite, (left, top))
|
||||
return target
|
||||
|
||||
|
||||
def connected_components(image: Image.Image) -> list[dict[str, object]]:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = image.size
|
||||
data = alpha.tobytes()
|
||||
visited = bytearray(width * height)
|
||||
components: list[dict[str, object]] = []
|
||||
|
||||
for start, alpha_value in enumerate(data):
|
||||
if alpha_value <= 16 or visited[start]:
|
||||
continue
|
||||
|
||||
stack = [start]
|
||||
visited[start] = 1
|
||||
pixels: list[int] = []
|
||||
min_x = width
|
||||
min_y = height
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
pixels.append(current)
|
||||
x = current % width
|
||||
y = current // width
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
|
||||
if x > 0:
|
||||
neighbor = current - 1
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if x + 1 < width:
|
||||
neighbor = current + 1
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if y > 0:
|
||||
neighbor = current - width
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
if y + 1 < height:
|
||||
neighbor = current + width
|
||||
if not visited[neighbor] and data[neighbor] > 16:
|
||||
visited[neighbor] = 1
|
||||
stack.append(neighbor)
|
||||
|
||||
components.append(
|
||||
{
|
||||
"pixels": pixels,
|
||||
"area": len(pixels),
|
||||
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
|
||||
"center_x": (min_x + max_x + 1) / 2,
|
||||
}
|
||||
)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def component_group_image(
|
||||
source: Image.Image,
|
||||
components: list[dict[str, object]],
|
||||
padding: int = 4,
|
||||
) -> Image.Image:
|
||||
width, height = source.size
|
||||
min_x = max(0, min(component["bbox"][0] for component in components) - padding)
|
||||
min_y = max(0, min(component["bbox"][1] for component in components) - padding)
|
||||
max_x = min(width, max(component["bbox"][2] for component in components) + padding)
|
||||
max_y = min(height, max(component["bbox"][3] for component in components) + padding)
|
||||
|
||||
output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
|
||||
source_pixels = source.load()
|
||||
output_pixels = output.load()
|
||||
for component in components:
|
||||
for pixel_index in component["pixels"]:
|
||||
x = pixel_index % width
|
||||
y = pixel_index // width
|
||||
output_pixels[x - min_x, y - min_y] = source_pixels[x, y]
|
||||
return output
|
||||
|
||||
|
||||
def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
|
||||
components = connected_components(strip)
|
||||
if not components:
|
||||
return None
|
||||
|
||||
largest_area = max(component["area"] for component in components)
|
||||
seed_threshold = max(120, largest_area * 0.20)
|
||||
seeds = [component for component in components if component["area"] >= seed_threshold]
|
||||
if len(seeds) < frame_count:
|
||||
seeds = sorted(components, key=lambda component: component["area"], reverse=True)[
|
||||
:frame_count
|
||||
]
|
||||
if len(seeds) < frame_count:
|
||||
return None
|
||||
|
||||
seeds = sorted(
|
||||
sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count],
|
||||
key=lambda component: component["center_x"],
|
||||
)
|
||||
seed_ids = {id(seed) for seed in seeds}
|
||||
groups: list[list[dict[str, object]]] = [[seed] for seed in seeds]
|
||||
noise_threshold = max(12, largest_area * 0.002)
|
||||
|
||||
for component in components:
|
||||
if id(component) in seed_ids or component["area"] < noise_threshold:
|
||||
continue
|
||||
nearest_index = min(
|
||||
range(len(seeds)),
|
||||
key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]),
|
||||
)
|
||||
groups[nearest_index].append(component)
|
||||
|
||||
return [fit_to_cell(component_group_image(strip, group)) for group in groups]
|
||||
|
||||
|
||||
def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
|
||||
slot_width = strip.width / frame_count
|
||||
frames = []
|
||||
for index in range(frame_count):
|
||||
left = round(index * slot_width)
|
||||
right = round((index + 1) * slot_width)
|
||||
crop = strip.crop((left, 0, right, strip.height))
|
||||
frames.append(fit_to_cell(crop))
|
||||
return frames
|
||||
|
||||
|
||||
def extract_state(
|
||||
strip_path: Path,
|
||||
state: str,
|
||||
output_root: Path,
|
||||
chroma_key: tuple[int, int, int],
|
||||
threshold: float,
|
||||
method: str,
|
||||
) -> dict[str, object]:
|
||||
frame_count = ROW_FRAME_COUNTS[state]
|
||||
with Image.open(strip_path) as opened:
|
||||
strip = remove_chroma_background(opened, chroma_key, threshold)
|
||||
|
||||
state_dir = output_root / state
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
frames = None
|
||||
used_method = method
|
||||
if method in {"auto", "components"}:
|
||||
frames = extract_component_frames(strip, frame_count)
|
||||
if frames is None and method == "components":
|
||||
raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
|
||||
if frames is not None:
|
||||
used_method = "components"
|
||||
|
||||
if frames is None:
|
||||
frames = extract_slot_frames(strip, frame_count)
|
||||
used_method = "slots"
|
||||
|
||||
outputs = []
|
||||
for index, frame in enumerate(frames):
|
||||
output = state_dir / f"{index:02d}.png"
|
||||
frame.save(output)
|
||||
outputs.append(str(output))
|
||||
return {"state": state, "frames": outputs, "method": used_method}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--decoded-dir", required=True)
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--states", default="all")
|
||||
parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.")
|
||||
parser.add_argument("--key-threshold", type=float, default=96.0)
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
choices=("auto", "components", "slots"),
|
||||
default="auto",
|
||||
help="Use connected sprite components when possible, or fixed equal slots.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
decoded_dir = Path(args.decoded_dir).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
chroma_key = load_chroma_key(decoded_dir, args.chroma_key)
|
||||
states = parse_states(args.states)
|
||||
manifest = []
|
||||
for state in states:
|
||||
strip_path = decoded_dir / f"{state}.png"
|
||||
if not strip_path.is_file():
|
||||
raise SystemExit(f"missing generated strip for {state}: {strip_path}")
|
||||
manifest.append(
|
||||
extract_state(
|
||||
strip_path,
|
||||
state,
|
||||
output_dir,
|
||||
chroma_key,
|
||||
args.key_threshold,
|
||||
args.method,
|
||||
)
|
||||
)
|
||||
|
||||
(output_dir / "frames-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"chroma_key": {
|
||||
"hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}",
|
||||
"rgb": list(chroma_key),
|
||||
"threshold": args.key_threshold,
|
||||
},
|
||||
"rows": manifest,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finalize a Codex pet run after all imagegen jobs are complete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def run(command: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
print("+ " + " ".join(command))
|
||||
return subprocess.run(command, check=check, text=True)
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, object]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def default_generated_images_root() -> Path:
|
||||
return default_codex_home() / "generated_images"
|
||||
|
||||
|
||||
def default_codex_home() -> Path:
|
||||
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
|
||||
|
||||
def manifest_path(raw: object, *, run_dir: Path, field: str, job_id: str) -> Path:
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise SystemExit(f"job {job_id} has no {field}")
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = run_dir / path
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def validate_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
|
||||
expected_hash = job.get("source_sha256")
|
||||
if not isinstance(expected_hash, str) or not expected_hash:
|
||||
raise SystemExit(
|
||||
f"job {job_id} is missing source_sha256; ingest visual outputs with "
|
||||
"record_imagegen_result.py instead of editing imagegen-jobs.json"
|
||||
)
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"job {job_id} source image no longer exists: {source}")
|
||||
if not output.is_file():
|
||||
raise SystemExit(f"job {job_id} decoded output is missing: {output}")
|
||||
source_hash = file_sha256(source)
|
||||
output_hash = file_sha256(output)
|
||||
if source_hash != expected_hash:
|
||||
raise SystemExit(f"job {job_id} source image hash does not match imagegen-jobs.json")
|
||||
if output_hash != expected_hash:
|
||||
raise SystemExit(
|
||||
f"job {job_id} decoded output does not match its recorded source image; "
|
||||
"do not rewrite decoded visual outputs locally"
|
||||
)
|
||||
|
||||
|
||||
def validate_mirror_hash(job: dict[str, object], *, source: Path, output: Path, job_id: str) -> None:
|
||||
if job_id != "running-left":
|
||||
raise SystemExit(f"job {job_id} may not use deterministic mirror provenance")
|
||||
if job.get("derived_from") != "running-right":
|
||||
raise SystemExit("running-left mirror job must derive from running-right")
|
||||
decision = job.get("mirror_decision")
|
||||
if not isinstance(decision, dict) or decision.get("approved") is not True:
|
||||
raise SystemExit(
|
||||
"running-left mirror job is missing an approved mirror_decision; "
|
||||
"use derive_running_left_from_running_right.py after visual review"
|
||||
)
|
||||
|
||||
expected_source_hash = job.get("source_sha256")
|
||||
expected_output_hash = job.get("output_sha256")
|
||||
if not isinstance(expected_source_hash, str) or not expected_source_hash:
|
||||
raise SystemExit("running-left mirror job is missing source_sha256")
|
||||
if not isinstance(expected_output_hash, str) or not expected_output_hash:
|
||||
raise SystemExit("running-left mirror job is missing output_sha256")
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"running-left mirror source image no longer exists: {source}")
|
||||
if not output.is_file():
|
||||
raise SystemExit(f"running-left mirrored output is missing: {output}")
|
||||
if source.name != "running-right.png" or source.parent.name != "decoded":
|
||||
raise SystemExit("running-left mirror source must be decoded/running-right.png")
|
||||
if output.name != "running-left.png" or output.parent.name != "decoded":
|
||||
raise SystemExit("running-left mirror output must be decoded/running-left.png")
|
||||
if file_sha256(source) != expected_source_hash:
|
||||
raise SystemExit("running-left mirror source hash does not match imagegen-jobs.json")
|
||||
if file_sha256(output) != expected_output_hash:
|
||||
raise SystemExit(
|
||||
"running-left mirrored output hash does not match imagegen-jobs.json; "
|
||||
"rerun derive_running_left_from_running_right.py"
|
||||
)
|
||||
with Image.open(source) as source_image, Image.open(output) as output_image:
|
||||
expected = ImageOps.mirror(source_image.convert("RGBA"))
|
||||
actual = output_image.convert("RGBA")
|
||||
if expected.size != actual.size or expected.tobytes() != actual.tobytes():
|
||||
raise SystemExit(
|
||||
"running-left mirrored output is not an exact horizontal mirror of running-right"
|
||||
)
|
||||
|
||||
|
||||
def validate_completed_job_source(
|
||||
job: dict[str, object],
|
||||
*,
|
||||
run_dir: Path,
|
||||
allow_synthetic_test_sources: bool,
|
||||
) -> None:
|
||||
job_id = str(job.get("id") or "")
|
||||
source = manifest_path(job.get("source_path"), run_dir=run_dir, field="source_path", job_id=job_id)
|
||||
output = manifest_path(job.get("output_path"), run_dir=run_dir, field="output_path", job_id=job_id)
|
||||
|
||||
blocked_flags = [
|
||||
flag
|
||||
for flag in ("deterministic_pet_row", "cute_raster_row", "local_raster_row")
|
||||
if job.get(flag)
|
||||
]
|
||||
if blocked_flags:
|
||||
raise SystemExit(
|
||||
f"job {job_id} was marked as a local/synthetic row ({', '.join(blocked_flags)}); "
|
||||
"regenerate it with $imagegen"
|
||||
)
|
||||
|
||||
if job.get("synthetic_test_source"):
|
||||
if not allow_synthetic_test_sources:
|
||||
raise SystemExit(
|
||||
f"job {job_id} uses a synthetic test source; rerun with real $imagegen output"
|
||||
)
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("secondary_fallback"):
|
||||
if job.get("source_provenance") != "secondary-fallback-image-api":
|
||||
raise SystemExit(f"job {job_id} has invalid secondary fallback provenance")
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("source_provenance") == "deterministic-mirror":
|
||||
validate_mirror_hash(job, source=source, output=output, job_id=job_id)
|
||||
return
|
||||
|
||||
if job.get("source_provenance") != "built-in-imagegen":
|
||||
raise SystemExit(
|
||||
f"job {job_id} was not recorded as a built-in $imagegen output; "
|
||||
"use record_imagegen_result.py with the selected $CODEX_HOME/generated_images/.../ig_*.png file"
|
||||
)
|
||||
if is_relative_to(source, run_dir):
|
||||
raise SystemExit(
|
||||
f"job {job_id} source image is inside the pet run directory; "
|
||||
"do not use locally generated row artifacts as visual sources"
|
||||
)
|
||||
generated_root = default_generated_images_root()
|
||||
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
|
||||
raise SystemExit(
|
||||
f"job {job_id} source image is not a built-in $imagegen output under "
|
||||
f"{generated_root}/.../ig_*.png"
|
||||
)
|
||||
validate_hash(job, source=source, output=output, job_id=job_id)
|
||||
|
||||
|
||||
def require_complete_jobs(run_dir: Path, *, allow_synthetic_test_sources: bool) -> None:
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_json(manifest_path)
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
incomplete = [
|
||||
str(job.get("id"))
|
||||
for job in jobs
|
||||
if isinstance(job, dict) and job.get("status", "pending") != "complete"
|
||||
]
|
||||
if incomplete:
|
||||
raise SystemExit(
|
||||
"imagegen jobs are not complete; run pet_job_status.py and finish: "
|
||||
+ ", ".join(incomplete)
|
||||
)
|
||||
for job in jobs:
|
||||
if isinstance(job, dict):
|
||||
validate_completed_job_source(
|
||||
job,
|
||||
run_dir=run_dir,
|
||||
allow_synthetic_test_sources=allow_synthetic_test_sources,
|
||||
)
|
||||
|
||||
|
||||
def review_failures(review: dict[str, object]) -> list[str]:
|
||||
rows = review.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
return ["review did not contain row-level results"]
|
||||
failures = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
errors = row.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
failures.append(f"{row.get('state')}: {'; '.join(str(error) for error in errors)}")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--allow-slot-extraction", action="store_true")
|
||||
parser.add_argument("--skip-videos", action="store_true")
|
||||
parser.add_argument("--skip-package", action="store_true")
|
||||
parser.add_argument(
|
||||
"--package-dir",
|
||||
default="",
|
||||
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
|
||||
)
|
||||
parser.add_argument("--ffmpeg", default="")
|
||||
parser.add_argument("--allow-synthetic-test-sources", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
scripts_dir = Path(__file__).resolve().parent
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
request = load_json(run_dir / "pet_request.json")
|
||||
pet_id = str(request.get("pet_id") or "")
|
||||
display_name = str(request.get("display_name") or "")
|
||||
description = str(request.get("description") or "")
|
||||
if not pet_id or not display_name or not description:
|
||||
raise SystemExit("pet_request.json is missing pet_id, display_name, or description")
|
||||
|
||||
require_complete_jobs(
|
||||
run_dir,
|
||||
allow_synthetic_test_sources=args.allow_synthetic_test_sources,
|
||||
)
|
||||
|
||||
final_dir = run_dir / "final"
|
||||
qa_dir = run_dir / "qa"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
qa_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "extract_strip_frames.py"),
|
||||
"--decoded-dir",
|
||||
str(run_dir / "decoded"),
|
||||
"--output-dir",
|
||||
str(run_dir / "frames"),
|
||||
"--states",
|
||||
"all",
|
||||
"--method",
|
||||
"auto",
|
||||
]
|
||||
)
|
||||
|
||||
review_path = qa_dir / "review.json"
|
||||
inspect_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "inspect_frames.py"),
|
||||
"--frames-root",
|
||||
str(run_dir / "frames"),
|
||||
"--json-out",
|
||||
str(review_path),
|
||||
]
|
||||
if not args.allow_slot_extraction:
|
||||
inspect_command.append("--require-components")
|
||||
run(inspect_command, check=False)
|
||||
review = load_json(review_path)
|
||||
if not review.get("ok"):
|
||||
failures = review_failures(review)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"review": str(review_path),
|
||||
"repair_hint": "Run queue_pet_repairs.py, regenerate the reopened row jobs with $imagegen, then finalize again.",
|
||||
"failures": failures,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "compose_atlas.py"),
|
||||
"--frames-root",
|
||||
str(run_dir / "frames"),
|
||||
"--output",
|
||||
str(final_dir / "spritesheet.png"),
|
||||
"--webp-output",
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
]
|
||||
)
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "validate_atlas.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--json-out",
|
||||
str(final_dir / "validation.json"),
|
||||
]
|
||||
)
|
||||
run(
|
||||
[
|
||||
sys.executable,
|
||||
str(scripts_dir / "make_contact_sheet.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--output",
|
||||
str(qa_dir / "contact-sheet.png"),
|
||||
]
|
||||
)
|
||||
|
||||
if not args.skip_videos:
|
||||
video_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "render_animation_videos.py"),
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--output-dir",
|
||||
str(qa_dir / "videos"),
|
||||
]
|
||||
if args.ffmpeg:
|
||||
video_command.extend(["--ffmpeg", args.ffmpeg])
|
||||
run(video_command)
|
||||
|
||||
if not args.skip_package:
|
||||
package_command = [
|
||||
sys.executable,
|
||||
str(scripts_dir / "package_custom_pet.py"),
|
||||
"--pet-name",
|
||||
pet_id,
|
||||
"--display-name",
|
||||
display_name,
|
||||
"--description",
|
||||
description,
|
||||
"--spritesheet",
|
||||
str(final_dir / "spritesheet.webp"),
|
||||
"--force",
|
||||
]
|
||||
if args.package_dir:
|
||||
package_command.extend(["--output-dir", str(Path(args.package_dir).expanduser().resolve())])
|
||||
run(package_command)
|
||||
|
||||
package_dir = None
|
||||
if not args.skip_package:
|
||||
package_dir = (
|
||||
Path(args.package_dir).expanduser().resolve()
|
||||
if args.package_dir
|
||||
else default_codex_home() / "pets" / pet_id
|
||||
)
|
||||
|
||||
summary = {
|
||||
"ok": True,
|
||||
"run_dir": str(run_dir),
|
||||
"spritesheet": str(final_dir / "spritesheet.webp"),
|
||||
"validation": str(final_dir / "validation.json"),
|
||||
"contact_sheet": str(qa_dir / "contact-sheet.png"),
|
||||
"review": str(review_path),
|
||||
"videos": None if args.skip_videos else str(qa_dir / "videos"),
|
||||
"package": None if package_dir is None else str(package_dir),
|
||||
}
|
||||
summary_path = qa_dir / "run-summary.json"
|
||||
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Secondary image generation fallback for Codex pet base art and row strips."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ALL_STATES = [
|
||||
"idle",
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"review",
|
||||
]
|
||||
CANONICAL_BASE_PATH = "references/canonical-base.png"
|
||||
|
||||
|
||||
def parse_states(raw: str) -> list[str]:
|
||||
if raw.strip().lower() == "all":
|
||||
return ALL_STATES
|
||||
states = [item.strip() for item in raw.split(",") if item.strip()]
|
||||
unknown = sorted(set(states) - set(ALL_STATES))
|
||||
if unknown:
|
||||
raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
|
||||
return states
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def manifest_jobs(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def select_jobs(
|
||||
manifest: dict[str, object],
|
||||
*,
|
||||
states: list[str],
|
||||
skip_base: bool,
|
||||
job_ids: list[str],
|
||||
) -> list[dict[str, object]]:
|
||||
selected_ids = set(job_ids)
|
||||
if not selected_ids:
|
||||
if not skip_base:
|
||||
selected_ids.add("base")
|
||||
selected_ids.update(states)
|
||||
selected = [job for job in manifest_jobs(manifest) if job.get("id") in selected_ids]
|
||||
missing = selected_ids - {str(job.get("id")) for job in selected}
|
||||
if missing:
|
||||
raise SystemExit(f"unknown job id(s): {', '.join(sorted(missing))}")
|
||||
return selected
|
||||
|
||||
|
||||
def run_image_edit(
|
||||
*,
|
||||
model: str,
|
||||
prompt_file: Path,
|
||||
image_paths: list[Path],
|
||||
output_json: Path,
|
||||
size: str,
|
||||
api_key: str,
|
||||
) -> dict[str, object]:
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"curl",
|
||||
"-sS",
|
||||
"-X",
|
||||
"POST",
|
||||
"https://api.openai.com/v1/images/edits",
|
||||
"-H",
|
||||
f"Authorization: Bearer {api_key}",
|
||||
"-F",
|
||||
f"model={model}",
|
||||
]
|
||||
for image_path in image_paths:
|
||||
command.extend(["-F", f"image[]=@{image_path}"])
|
||||
command.extend(
|
||||
[
|
||||
"-F",
|
||||
f"prompt=<{prompt_file}",
|
||||
"-F",
|
||||
f"size={size}",
|
||||
"-F",
|
||||
"output_format=png",
|
||||
"-o",
|
||||
str(output_json),
|
||||
]
|
||||
)
|
||||
subprocess.run(command, check=True)
|
||||
response = json.loads(output_json.read_text(encoding="utf-8"))
|
||||
if response.get("error"):
|
||||
raise SystemExit(json.dumps(response["error"], indent=2))
|
||||
return response
|
||||
|
||||
|
||||
def run_image_generation(
|
||||
*,
|
||||
model: str,
|
||||
prompt_file: Path,
|
||||
output_json: Path,
|
||||
size: str,
|
||||
api_key: str,
|
||||
) -> dict[str, object]:
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
"curl",
|
||||
"-sS",
|
||||
"-X",
|
||||
"POST",
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
"-H",
|
||||
f"Authorization: Bearer {api_key}",
|
||||
"-F",
|
||||
f"model={model}",
|
||||
"-F",
|
||||
f"prompt=<{prompt_file}",
|
||||
"-F",
|
||||
f"size={size}",
|
||||
"-F",
|
||||
"output_format=png",
|
||||
"-o",
|
||||
str(output_json),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
response = json.loads(output_json.read_text(encoding="utf-8"))
|
||||
if response.get("error"):
|
||||
raise SystemExit(json.dumps(response["error"], indent=2))
|
||||
return response
|
||||
|
||||
|
||||
def decode_response(response: dict[str, object], output_image: Path) -> None:
|
||||
data = response.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise SystemExit("image API response did not contain data[0]")
|
||||
first = data[0]
|
||||
if not isinstance(first, dict) or not isinstance(first.get("b64_json"), str):
|
||||
raise SystemExit("image API response did not contain data[0].b64_json")
|
||||
output_image.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_image.write_bytes(base64.b64decode(first["b64_json"]))
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def complete_job(job: dict[str, object], output_path: Path) -> None:
|
||||
job["status"] = "complete"
|
||||
job["source_path"] = str(output_path)
|
||||
job["source_provenance"] = "secondary-fallback-image-api"
|
||||
job["source_sha256"] = file_sha256(output_path)
|
||||
job["output_sha256"] = file_sha256(output_path)
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
job["secondary_fallback"] = True
|
||||
for key in [
|
||||
"last_error",
|
||||
"synthetic_test_source",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
|
||||
|
||||
def write_canonical_base(
|
||||
run_dir: Path, manifest: dict[str, object], output_image: Path
|
||||
) -> None:
|
||||
canonical = run_dir / CANONICAL_BASE_PATH
|
||||
canonical.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output_image, canonical)
|
||||
reference = {
|
||||
"path": CANONICAL_BASE_PATH,
|
||||
"source_job": "base",
|
||||
"sha256": file_sha256(canonical),
|
||||
}
|
||||
manifest["canonical_identity_reference"] = reference
|
||||
request_path = run_dir / "pet_request.json"
|
||||
if request_path.exists():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
request["canonical_identity_reference"] = reference
|
||||
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def path_list(run_dir: Path, job: dict[str, object]) -> list[Path]:
|
||||
inputs = job.get("input_images")
|
||||
if not isinstance(inputs, list):
|
||||
raise SystemExit(f"job {job.get('id')} has invalid input_images")
|
||||
paths = []
|
||||
for item in inputs:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise SystemExit(f"job {job.get('id')} has invalid input image entry")
|
||||
path = run_dir / item["path"]
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"input image for job {job.get('id')} not found: {path}")
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--model", default="gpt-image-2")
|
||||
parser.add_argument("--size", default="1024x1024")
|
||||
parser.add_argument("--states", default="all")
|
||||
parser.add_argument("--job-id", action="append", default=[])
|
||||
parser.add_argument("--skip-base", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise SystemExit("OPENAI_API_KEY is not set")
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_manifest(run_dir)
|
||||
jobs = select_jobs(
|
||||
manifest,
|
||||
states=parse_states(args.states),
|
||||
skip_base=args.skip_base,
|
||||
job_ids=args.job_id,
|
||||
)
|
||||
raw_dir = run_dir / "raw"
|
||||
|
||||
completed = []
|
||||
for job in jobs:
|
||||
job_id = str(job.get("id"))
|
||||
prompt_raw = job.get("prompt_file")
|
||||
output_raw = job.get("output_path")
|
||||
if not isinstance(prompt_raw, str) or not isinstance(output_raw, str):
|
||||
raise SystemExit(f"job {job_id} is missing prompt_file or output_path")
|
||||
prompt_file = run_dir / prompt_raw
|
||||
output_image = run_dir / output_raw
|
||||
print(f"Generating {job_id} with secondary fallback")
|
||||
image_paths = path_list(run_dir, job)
|
||||
if image_paths:
|
||||
response = run_image_edit(
|
||||
model=args.model,
|
||||
prompt_file=prompt_file,
|
||||
image_paths=image_paths,
|
||||
output_json=raw_dir / f"{job_id}.response.json",
|
||||
size=args.size,
|
||||
api_key=api_key,
|
||||
)
|
||||
else:
|
||||
response = run_image_generation(
|
||||
model=args.model,
|
||||
prompt_file=prompt_file,
|
||||
output_json=raw_dir / f"{job_id}.response.json",
|
||||
size=args.size,
|
||||
api_key=api_key,
|
||||
)
|
||||
decode_response(response, output_image)
|
||||
complete_job(job, output_image)
|
||||
if job_id == "base":
|
||||
job["canonical_reference_path"] = CANONICAL_BASE_PATH
|
||||
write_canonical_base(run_dir, manifest, output_image)
|
||||
completed.append({"job_id": job_id, "output": str(output_image)})
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"ok": True, "completed": completed}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect extracted Codex pet frames before atlas composition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ROW_FRAME_COUNTS = {
|
||||
"idle": 6,
|
||||
"running-right": 8,
|
||||
"running-left": 8,
|
||||
"waving": 4,
|
||||
"jumping": 5,
|
||||
"failed": 8,
|
||||
"waiting": 6,
|
||||
"running": 6,
|
||||
"review": 6,
|
||||
}
|
||||
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
alpha = image if image.mode == "L" else image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def edge_alpha_count(image: Image.Image, margin: int) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
width, height = alpha.size
|
||||
total = 0
|
||||
for box in (
|
||||
(0, 0, width, margin),
|
||||
(0, height - margin, width, height),
|
||||
(0, 0, margin, height),
|
||||
(width - margin, 0, width, height),
|
||||
):
|
||||
total += alpha_nonzero_count(alpha.crop(box))
|
||||
return total
|
||||
|
||||
|
||||
def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
|
||||
return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
|
||||
|
||||
|
||||
def chroma_adjacent_count(
|
||||
image: Image.Image,
|
||||
chroma_key: tuple[int, int, int] | None,
|
||||
threshold: float,
|
||||
) -> int:
|
||||
if chroma_key is None:
|
||||
return 0
|
||||
rgba = image.convert("RGBA")
|
||||
data = rgba.tobytes()
|
||||
count = 0
|
||||
for index in range(0, len(data), 4):
|
||||
red, green, blue, alpha = data[index : index + 4]
|
||||
if alpha > 16 and color_distance((red, green, blue), chroma_key) <= threshold:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def frame_files(state_dir: Path) -> list[Path]:
|
||||
if not state_dir.is_dir():
|
||||
return []
|
||||
return sorted(path for path in state_dir.iterdir() if path.suffix.lower() in IMAGE_SUFFIXES)
|
||||
|
||||
|
||||
def load_manifest(frames_root: Path) -> dict[str, dict[str, object]]:
|
||||
manifest_path = frames_root / "frames-manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return {}
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
rows = manifest.get("rows", [])
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
return {
|
||||
row["state"]: row
|
||||
for row in rows
|
||||
if isinstance(row, dict) and isinstance(row.get("state"), str)
|
||||
}
|
||||
|
||||
|
||||
def load_chroma_key(frames_root: Path) -> tuple[int, int, int] | None:
|
||||
manifest_path = frames_root / "frames-manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return None
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
chroma_key = manifest.get("chroma_key")
|
||||
if not isinstance(chroma_key, dict):
|
||||
return None
|
||||
rgb = chroma_key.get("rgb")
|
||||
if (
|
||||
not isinstance(rgb, list)
|
||||
or len(rgb) != 3
|
||||
or not all(isinstance(value, int) for value in rgb)
|
||||
):
|
||||
return None
|
||||
return (rgb[0], rgb[1], rgb[2])
|
||||
|
||||
|
||||
def inspect_state(
|
||||
frames_root: Path,
|
||||
state: str,
|
||||
expected_count: int,
|
||||
manifest_rows: dict[str, dict[str, object]],
|
||||
chroma_key: tuple[int, int, int] | None,
|
||||
args: argparse.Namespace,
|
||||
) -> dict[str, object]:
|
||||
state_dir = frames_root / state
|
||||
files = frame_files(state_dir)
|
||||
row_errors: list[str] = []
|
||||
row_warnings: list[str] = []
|
||||
frames: list[dict[str, object]] = []
|
||||
areas: list[int] = []
|
||||
manifest_row = manifest_rows.get(state, {})
|
||||
method = manifest_row.get("method")
|
||||
|
||||
if len(files) != expected_count:
|
||||
row_errors.append(f"expected {expected_count} frame files for {state}, found {len(files)}")
|
||||
|
||||
if args.require_components and method and method != "components":
|
||||
row_errors.append(
|
||||
f"{state} used extraction method {method}; regenerate the row or inspect slot slicing"
|
||||
)
|
||||
elif method and method != "components":
|
||||
row_warnings.append(
|
||||
f"{state} used extraction method {method}; component extraction is preferred"
|
||||
)
|
||||
|
||||
for index, frame_path in enumerate(files[:expected_count]):
|
||||
with Image.open(frame_path) as opened:
|
||||
frame = opened.convert("RGBA")
|
||||
nontransparent = alpha_nonzero_count(frame)
|
||||
bbox = frame.getbbox()
|
||||
edge_pixels = edge_alpha_count(frame, args.edge_margin)
|
||||
chroma_adjacent_pixels = chroma_adjacent_count(
|
||||
frame,
|
||||
chroma_key,
|
||||
args.chroma_adjacent_threshold,
|
||||
)
|
||||
info = {
|
||||
"index": index,
|
||||
"file": str(frame_path),
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"nontransparent_pixels": nontransparent,
|
||||
"bbox": list(bbox) if bbox else None,
|
||||
"edge_pixels": edge_pixels,
|
||||
"chroma_adjacent_pixels": chroma_adjacent_pixels,
|
||||
}
|
||||
frames.append(info)
|
||||
areas.append(nontransparent)
|
||||
|
||||
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} is {frame.width}x{frame.height}; expected {CELL_WIDTH}x{CELL_HEIGHT}"
|
||||
)
|
||||
if nontransparent < args.min_used_pixels:
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} is empty or too sparse ({nontransparent} pixels)"
|
||||
)
|
||||
if edge_pixels > args.edge_pixel_threshold:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} has {edge_pixels} non-transparent pixels near the cell edge"
|
||||
)
|
||||
if chroma_adjacent_pixels > args.chroma_adjacent_pixel_threshold:
|
||||
row_errors.append(
|
||||
f"{state} frame {index:02d} has {chroma_adjacent_pixels} non-transparent pixels close to the chroma key"
|
||||
)
|
||||
|
||||
if areas:
|
||||
row_median = median(areas)
|
||||
for index, area in enumerate(areas[:expected_count]):
|
||||
if row_median > 0 and area < row_median * args.small_outlier_ratio:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} is much smaller than the row median ({area} vs {row_median:.0f})"
|
||||
)
|
||||
if row_median > 0 and area > row_median * args.large_outlier_ratio:
|
||||
row_warnings.append(
|
||||
f"{state} frame {index:02d} is much larger than the row median ({area} vs {row_median:.0f})"
|
||||
)
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"expected_frames": expected_count,
|
||||
"actual_frames": len(files),
|
||||
"extraction_method": method,
|
||||
"ok": not row_errors,
|
||||
"errors": row_errors,
|
||||
"warnings": row_warnings,
|
||||
"frames": frames,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--frames-root", required=True)
|
||||
parser.add_argument("--json-out", required=True)
|
||||
parser.add_argument("--min-used-pixels", type=int, default=400)
|
||||
parser.add_argument("--edge-margin", type=int, default=2)
|
||||
parser.add_argument("--edge-pixel-threshold", type=int, default=24)
|
||||
parser.add_argument("--chroma-adjacent-threshold", type=float, default=150.0)
|
||||
parser.add_argument("--chroma-adjacent-pixel-threshold", type=int, default=800)
|
||||
parser.add_argument("--small-outlier-ratio", type=float, default=0.35)
|
||||
parser.add_argument("--large-outlier-ratio", type=float, default=2.75)
|
||||
parser.add_argument(
|
||||
"--require-components",
|
||||
action="store_true",
|
||||
help="Fail rows that fell back to equal-slot extraction.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
frames_root = Path(args.frames_root).expanduser().resolve()
|
||||
manifest_rows = load_manifest(frames_root)
|
||||
chroma_key = load_chroma_key(frames_root)
|
||||
rows = [
|
||||
inspect_state(frames_root, state, count, manifest_rows, chroma_key, args)
|
||||
for state, count in ROW_FRAME_COUNTS.items()
|
||||
]
|
||||
errors = [error for row in rows for error in row["errors"]]
|
||||
warnings = [warning for row in rows for warning in row["warnings"]]
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"frames_root": str(frames_root),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
json_out = Path(args.json_out).expanduser().resolve()
|
||||
json_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "rows"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a labeled contact sheet from a Codex pet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
LABEL_HEIGHT = 22
|
||||
ROW_NAMES = [
|
||||
"idle",
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
"failed",
|
||||
"waiting",
|
||||
"running",
|
||||
"review",
|
||||
]
|
||||
USED_COUNTS = [6, 8, 8, 4, 5, 8, 6, 6, 6]
|
||||
|
||||
|
||||
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
|
||||
image = Image.new("RGB", size, "#ffffff")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], square):
|
||||
for x in range(0, size[0], square):
|
||||
if (x // square + y // square) % 2:
|
||||
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
|
||||
return image
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--scale", type=float, default=0.5)
|
||||
args = parser.parse_args()
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
|
||||
cell_w = max(1, round(CELL_WIDTH * args.scale))
|
||||
cell_h = max(1, round(CELL_HEIGHT * args.scale))
|
||||
width = COLUMNS * cell_w
|
||||
height = ROWS * (cell_h + LABEL_HEIGHT)
|
||||
sheet = Image.new("RGB", (width, height), "#f7f7f7")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row in range(ROWS):
|
||||
y = row * (cell_h + LABEL_HEIGHT)
|
||||
draw.rectangle((0, y, width, y + LABEL_HEIGHT - 1), fill="#111111")
|
||||
draw.text((6, y + 5), f"row {row}: {ROW_NAMES[row]}", fill="#ffffff", font=font)
|
||||
draw.text(
|
||||
(width - 92, y + 5),
|
||||
f"{USED_COUNTS[row]} frames",
|
||||
fill="#ffffff",
|
||||
font=font,
|
||||
)
|
||||
for column in range(COLUMNS):
|
||||
crop = atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
)
|
||||
crop = crop.resize((cell_w, cell_h), Image.Resampling.LANCZOS)
|
||||
bg = checker((cell_w, cell_h))
|
||||
bg.paste(crop, (0, 0), crop)
|
||||
x = column * cell_w
|
||||
sheet.paste(bg, (x, y + LABEL_HEIGHT))
|
||||
outline = "#18a058" if column < USED_COUNTS[row] else "#cc3344"
|
||||
draw.rectangle(
|
||||
(x, y + LABEL_HEIGHT, x + cell_w - 1, y + LABEL_HEIGHT + cell_h - 1),
|
||||
outline=outline,
|
||||
)
|
||||
draw.text((x + 4, y + LABEL_HEIGHT + 4), str(column), fill="#111111", font=font)
|
||||
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(output)
|
||||
print(f"wrote {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Package a validated atlas as a local Codex pet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ATLAS_SIZE = (1536, 1872)
|
||||
|
||||
|
||||
def default_codex_home() -> Path:
|
||||
return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
return value.strip("-")
|
||||
|
||||
|
||||
def validate_spritesheet(path: Path) -> str:
|
||||
with Image.open(path) as image:
|
||||
if image.size != ATLAS_SIZE:
|
||||
raise SystemExit(
|
||||
f"expected {ATLAS_SIZE[0]}x{ATLAS_SIZE[1]}, got {image.width}x{image.height}"
|
||||
)
|
||||
if image.format not in {"PNG", "WEBP"}:
|
||||
raise SystemExit(f"expected PNG or WebP, got {image.format}")
|
||||
return str(image.format)
|
||||
|
||||
|
||||
def write_webp_spritesheet(source: Path, target: Path, source_format: str) -> None:
|
||||
if source_format == "WEBP":
|
||||
shutil.copy2(source, target)
|
||||
return
|
||||
with Image.open(source) as image:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.convert("RGBA").save(
|
||||
target,
|
||||
format="WEBP",
|
||||
lossless=True,
|
||||
quality=100,
|
||||
method=6,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pet-name", default="")
|
||||
parser.add_argument("--display-name", default="")
|
||||
parser.add_argument("--description", required=True)
|
||||
parser.add_argument("--spritesheet", required=True)
|
||||
parser.add_argument("--codex-home", default=str(default_codex_home()))
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
help="Exact pet package directory. Defaults to ${CODEX_HOME:-$HOME/.codex}/pets/<pet-name>.",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
raw_pet_name = (args.pet_name or args.display_name).strip()
|
||||
if not raw_pet_name:
|
||||
raise SystemExit("pet name is required")
|
||||
pet_id = slugify(raw_pet_name)
|
||||
if not pet_id:
|
||||
raise SystemExit("pet name must contain at least one letter or digit")
|
||||
display_name = (args.display_name or raw_pet_name).strip()
|
||||
|
||||
source = Path(args.spritesheet).expanduser().resolve()
|
||||
source_format = validate_spritesheet(source)
|
||||
target_dir = (
|
||||
Path(args.output_dir).expanduser().resolve()
|
||||
if args.output_dir
|
||||
else Path(args.codex_home).expanduser().resolve() / "pets" / pet_id
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_sheet = target_dir / "spritesheet.webp"
|
||||
manifest_path = target_dir / "pet.json"
|
||||
if not args.force and (target_sheet.exists() or manifest_path.exists()):
|
||||
raise SystemExit(f"{target_dir} already contains pet files; pass --force to overwrite")
|
||||
|
||||
write_webp_spritesheet(source, target_sheet, source_format)
|
||||
manifest = {
|
||||
"id": pet_id,
|
||||
"displayName": display_name,
|
||||
"description": args.description,
|
||||
"spritesheetPath": target_sheet.name,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{"ok": True, "pet_dir": str(target_dir), "manifest": str(manifest_path)}, indent=2
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show ready and pending $imagegen jobs for a Codex pet run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_manifest(run_dir: Path) -> dict[str, object]:
|
||||
path = run_dir / "imagegen-jobs.json"
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def jobs(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
raw = manifest.get("jobs")
|
||||
if not isinstance(raw, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in raw if isinstance(job, dict)]
|
||||
|
||||
|
||||
def completed_ids(manifest: dict[str, object]) -> set[str]:
|
||||
return {
|
||||
str(job["id"])
|
||||
for job in jobs(manifest)
|
||||
if job.get("status") == "complete" and isinstance(job.get("id"), str)
|
||||
}
|
||||
|
||||
|
||||
def missing_deps(job: dict[str, object], completed: set[str]) -> list[str]:
|
||||
deps = job.get("depends_on", [])
|
||||
if not isinstance(deps, list):
|
||||
return []
|
||||
return [dep for dep in deps if isinstance(dep, str) and dep not in completed]
|
||||
|
||||
|
||||
def job_view(
|
||||
job: dict[str, object], run_dir: Path, completed: set[str]
|
||||
) -> dict[str, object]:
|
||||
prompt_file = job.get("prompt_file")
|
||||
output_path = job.get("output_path")
|
||||
inputs = (
|
||||
job.get("input_images") if isinstance(job.get("input_images"), list) else []
|
||||
)
|
||||
input_images = []
|
||||
for item in inputs:
|
||||
path = (
|
||||
run_dir / item["path"]
|
||||
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
||||
else None
|
||||
)
|
||||
input_images.append(
|
||||
{
|
||||
"path": str(path) if path else None,
|
||||
"role": item.get("role") if isinstance(item, dict) else None,
|
||||
"exists": path.is_file() if path else False,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": job.get("id"),
|
||||
"kind": job.get("kind"),
|
||||
"status": job.get("status", "pending"),
|
||||
"prompt_file": str(run_dir / prompt_file)
|
||||
if isinstance(prompt_file, str)
|
||||
else None,
|
||||
"input_images": input_images,
|
||||
"output_path": str(run_dir / output_path)
|
||||
if isinstance(output_path, str)
|
||||
else None,
|
||||
"missing_dependencies": missing_deps(job, completed),
|
||||
"repair_attempt": job.get("repair_attempt", 0),
|
||||
"generation_skill": job.get("generation_skill"),
|
||||
"requires_grounded_generation": job.get("requires_grounded_generation", False),
|
||||
"allow_prompt_only_generation": job.get("allow_prompt_only_generation", False),
|
||||
"identity_reference_paths": job.get("identity_reference_paths", []),
|
||||
"mirror_policy": job.get("mirror_policy", {}),
|
||||
"derived_from": job.get("derived_from"),
|
||||
"source_provenance": job.get("source_provenance"),
|
||||
"mirror_decision": job.get("mirror_decision"),
|
||||
"recording_owner": job.get("recording_owner", "parent"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
manifest = load_manifest(run_dir)
|
||||
completed = completed_ids(manifest)
|
||||
pending = [
|
||||
job for job in jobs(manifest) if job.get("status", "pending") != "complete"
|
||||
]
|
||||
ready = [job for job in pending if not missing_deps(job, completed)]
|
||||
blocked = [job for job in pending if missing_deps(job, completed)]
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"run_dir": str(run_dir),
|
||||
"counts": {
|
||||
"total": len(jobs(manifest)),
|
||||
"complete": len(completed),
|
||||
"ready": len(ready),
|
||||
"blocked": len(blocked),
|
||||
},
|
||||
"ready_jobs": [job_view(job, run_dir, completed) for job in ready],
|
||||
"blocked_jobs": [job_view(job, run_dir, completed) for job in blocked],
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,674 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a Codex pet run folder, prompts, and imagegen job manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from PIL import ImageDraw
|
||||
|
||||
ATLAS = {"columns": 8, "rows": 9, "cell_width": 192, "cell_height": 208}
|
||||
ATLAS["width"] = ATLAS["columns"] * ATLAS["cell_width"]
|
||||
ATLAS["height"] = ATLAS["rows"] * ATLAS["cell_height"]
|
||||
|
||||
ROWS = [
|
||||
("idle", 0, 6, "neutral breathing/blinking loop"),
|
||||
("running-right", 1, 8, "rightward locomotion loop"),
|
||||
("running-left", 2, 8, "leftward locomotion loop"),
|
||||
("waving", 3, 4, "greeting gesture with raised wave and return"),
|
||||
("jumping", 4, 5, "anticipation, lift, peak, descent, settle"),
|
||||
("failed", 5, 8, "sad, failed, or deflated reaction"),
|
||||
("waiting", 6, 6, "patient waiting loop with small motion"),
|
||||
("running", 7, 6, "generic in-place running loop"),
|
||||
("review", 8, 6, "focused inspecting or review loop"),
|
||||
]
|
||||
|
||||
TRANSPARENCY_ARTIFACT_RULES = [
|
||||
"Prefer pose, expression, and silhouette changes over decorative effects.",
|
||||
"Effects are allowed only when they are state-relevant, opaque, hard-edged, pixel-style, fully inside the same frame slot, and physically touching or overlapping the pet silhouette.",
|
||||
"Allowed attached effects can include a tear touching the face, a small smoke puff touching the pet or prop, or tiny stars overlapping the pet during a failed/dizzy reaction.",
|
||||
"Do not draw detached effects: floating stars, loose sparkles, floating punctuation, floating icons, falling tear drops, separated smoke clouds, loose dust, disconnected outline bits, or stray pixels.",
|
||||
"Do not draw wave marks, motion arcs, speed lines, action streaks, afterimages, blur, smears, halos, glows, auras, floor patches, cast shadows, contact shadows, drop shadows, oval floor shadows, landing marks, or impact bursts.",
|
||||
"Do not include text, labels, frame numbers, visible grids, guide marks, speech bubbles, thought bubbles, UI panels, code snippets, scenery, checkerboard transparency, white backgrounds, or black backgrounds.",
|
||||
"Do not use the chroma-key color or chroma-key-adjacent colors in the pet, prop, effects, highlights, shadows, or outlines.",
|
||||
"Reject any pose that is cropped, overlaps another pose, crosses into a neighboring frame slot, or creates a separate disconnected component that is not attached to the pet.",
|
||||
]
|
||||
|
||||
STATE_REQUIREMENTS = {
|
||||
"waving": [
|
||||
"Show the greeting through paw pose only: paw down, paw raised, paw tilted, paw returning.",
|
||||
"Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the paw.",
|
||||
],
|
||||
"jumping": [
|
||||
"Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.",
|
||||
"Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.",
|
||||
"Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.",
|
||||
],
|
||||
"failed": [
|
||||
"Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.",
|
||||
"Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.",
|
||||
"Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.",
|
||||
],
|
||||
"review": [
|
||||
"Show review through lean, blink, narrowed eyes, head tilt, or paw position.",
|
||||
"Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.",
|
||||
],
|
||||
"running-right": [
|
||||
"Show locomotion through body, limb, and prop movement only.",
|
||||
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
|
||||
],
|
||||
"running-left": [
|
||||
"Show locomotion through body, limb, and prop movement only.",
|
||||
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
|
||||
],
|
||||
"running": [
|
||||
"Show in-place running through body, limb, and prop movement only.",
|
||||
"Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
|
||||
],
|
||||
}
|
||||
|
||||
DIGITAL_PET_STYLE = (
|
||||
"Codex digital pet sprite style: pixel-art-adjacent low-resolution mascot sprite, "
|
||||
"compact chibi proportions, chunky whole-body silhouette, thick dark 1-2 px outline, "
|
||||
"visible stepped/pixel edges, limited palette, flat cel shading with at most one "
|
||||
"small highlight and one shadow step, simple readable face, tiny limbs, and no "
|
||||
"detail that disappears at 192x208. Avoid polished illustration, painterly "
|
||||
"rendering, anime key art, 3D render, vector app-icon polish, glossy lighting, "
|
||||
"soft gradients, realistic fur or material texture, anti-aliased high-detail "
|
||||
"edges, and complex tiny accessories."
|
||||
)
|
||||
|
||||
CHROMA_KEY_CANDIDATES = [
|
||||
("magenta", "#FF00FF"),
|
||||
("cyan", "#00FFFF"),
|
||||
("yellow", "#FFFF00"),
|
||||
("blue", "#0000FF"),
|
||||
("orange", "#FF7F00"),
|
||||
("green", "#00FF00"),
|
||||
]
|
||||
|
||||
DEFAULT_PET_NAME = "Sprout"
|
||||
CANONICAL_BASE_PATH = "references/canonical-base.png"
|
||||
LAYOUT_GUIDE_DIR = "references/layout-guides"
|
||||
LAYOUT_GUIDE_SAFE_MARGIN_X = 18
|
||||
LAYOUT_GUIDE_SAFE_MARGIN_Y = 16
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value)
|
||||
value = re.sub(r"-{2,}", "-", value)
|
||||
return value.strip("-")
|
||||
|
||||
|
||||
def display_from_slug(value: str) -> str:
|
||||
words = [word for word in re.split(r"[^a-zA-Z0-9]+", value.strip()) if word]
|
||||
return " ".join(word.capitalize() for word in words)
|
||||
|
||||
|
||||
def concept_words(value: str) -> list[str]:
|
||||
stop_words = {
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"app",
|
||||
"based",
|
||||
"codex",
|
||||
"compact",
|
||||
"digital",
|
||||
"for",
|
||||
"from",
|
||||
"in",
|
||||
"of",
|
||||
"on",
|
||||
"pet",
|
||||
"ready",
|
||||
"small",
|
||||
"the",
|
||||
"to",
|
||||
"with",
|
||||
}
|
||||
words = [
|
||||
word.lower()
|
||||
for word in re.findall(r"[a-zA-Z0-9]+", value)
|
||||
if word.lower() not in stop_words
|
||||
]
|
||||
return words
|
||||
|
||||
|
||||
def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str:
|
||||
for raw_value in [args.display_name, args.pet_name]:
|
||||
value = raw_value.strip()
|
||||
if value:
|
||||
return value
|
||||
|
||||
if args.pet_id.strip():
|
||||
display = display_from_slug(args.pet_id)
|
||||
if display:
|
||||
return display
|
||||
|
||||
for raw_value in [args.pet_notes, args.description]:
|
||||
words = concept_words(raw_value)
|
||||
if words:
|
||||
return words[0].capitalize()
|
||||
|
||||
for path in reference_paths:
|
||||
display = display_from_slug(path.stem)
|
||||
if display:
|
||||
return display
|
||||
|
||||
return DEFAULT_PET_NAME
|
||||
|
||||
|
||||
def sentence(value: str) -> str:
|
||||
value = " ".join(value.strip().split())
|
||||
if not value:
|
||||
return value
|
||||
if value[-1] not in ".!?":
|
||||
value += "."
|
||||
return value
|
||||
|
||||
|
||||
def infer_description(args: argparse.Namespace, reference_paths: list[Path]) -> str:
|
||||
if args.description.strip():
|
||||
return sentence(args.description)
|
||||
if args.pet_notes.strip():
|
||||
return sentence(f"A compact Codex digital pet: {args.pet_notes}")
|
||||
if reference_paths:
|
||||
return "A compact Codex digital pet based on the provided reference image."
|
||||
return "A compact original Codex digital pet ready for animation."
|
||||
|
||||
|
||||
def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> str:
|
||||
if args.pet_notes.strip():
|
||||
return args.pet_notes.strip()
|
||||
if args.description.strip():
|
||||
return args.description.strip().rstrip(".")
|
||||
if reference_paths:
|
||||
return "the pet shown in the reference image(s)"
|
||||
return "a compact original Codex digital pet"
|
||||
|
||||
|
||||
def default_output_dir(pet_id: str) -> Path:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
return Path.cwd() / "output" / "hatch-pet" / f"{pet_id}-{timestamp}"
|
||||
|
||||
|
||||
def rel(path: Path, root: Path) -> str:
|
||||
return str(path.resolve().relative_to(root.resolve()))
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
return {
|
||||
"path": str(path),
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"mode": image.mode,
|
||||
"format": image.format,
|
||||
}
|
||||
|
||||
|
||||
def draw_dashed_line(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
start: tuple[int, int],
|
||||
end: tuple[int, int],
|
||||
*,
|
||||
fill: str,
|
||||
dash: int = 8,
|
||||
gap: int = 6,
|
||||
) -> None:
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
if x1 == x2:
|
||||
step = dash + gap
|
||||
for y in range(min(y1, y2), max(y1, y2), step):
|
||||
draw.line((x1, y, x2, min(y + dash, max(y1, y2))), fill=fill)
|
||||
return
|
||||
if y1 == y2:
|
||||
step = dash + gap
|
||||
for x in range(min(x1, x2), max(x1, x2), step):
|
||||
draw.line((x, y1, min(x + dash, max(x1, x2)), y2), fill=fill)
|
||||
return
|
||||
raise ValueError("draw_dashed_line only supports horizontal or vertical lines")
|
||||
|
||||
|
||||
def create_layout_guide(path: Path, state: str, frames: int) -> dict[str, object]:
|
||||
width = frames * ATLAS["cell_width"]
|
||||
height = ATLAS["cell_height"]
|
||||
cell_width = ATLAS["cell_width"]
|
||||
image = Image.new("RGB", (width, height), "#f7f7f7")
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
for index in range(frames):
|
||||
left = index * cell_width
|
||||
right = left + cell_width - 1
|
||||
draw.rectangle((left, 0, right, height - 1), outline="#111111", width=2)
|
||||
|
||||
safe_left = left + LAYOUT_GUIDE_SAFE_MARGIN_X
|
||||
safe_top = LAYOUT_GUIDE_SAFE_MARGIN_Y
|
||||
safe_right = right - LAYOUT_GUIDE_SAFE_MARGIN_X
|
||||
safe_bottom = height - 1 - LAYOUT_GUIDE_SAFE_MARGIN_Y
|
||||
draw.rectangle(
|
||||
(safe_left, safe_top, safe_right, safe_bottom),
|
||||
outline="#2f80ed",
|
||||
width=2,
|
||||
)
|
||||
|
||||
center_x = left + cell_width // 2
|
||||
center_y = height // 2
|
||||
draw_dashed_line(
|
||||
draw,
|
||||
(center_x, safe_top),
|
||||
(center_x, safe_bottom),
|
||||
fill="#b8b8b8",
|
||||
)
|
||||
draw_dashed_line(
|
||||
draw,
|
||||
(safe_left, center_y),
|
||||
(safe_right, center_y),
|
||||
fill="#b8b8b8",
|
||||
)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(path)
|
||||
return {
|
||||
"state": state,
|
||||
"path": str(path),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"frames": frames,
|
||||
"cell_width": ATLAS["cell_width"],
|
||||
"cell_height": ATLAS["cell_height"],
|
||||
"safe_margin_x": LAYOUT_GUIDE_SAFE_MARGIN_X,
|
||||
"safe_margin_y": LAYOUT_GUIDE_SAFE_MARGIN_Y,
|
||||
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips",
|
||||
}
|
||||
|
||||
|
||||
def create_layout_guides(run_dir: Path) -> list[dict[str, object]]:
|
||||
guide_dir = run_dir / LAYOUT_GUIDE_DIR
|
||||
return [
|
||||
create_layout_guide(guide_dir / f"{state}.png", state, frames)
|
||||
for state, _row, frames, _purpose in ROWS
|
||||
]
|
||||
|
||||
|
||||
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
|
||||
raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
|
||||
return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
|
||||
|
||||
|
||||
def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
|
||||
return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
|
||||
|
||||
|
||||
def sampled_reference_pixels(paths: list[Path]) -> list[tuple[int, int, int]]:
|
||||
pixels: list[tuple[int, int, int]] = []
|
||||
for path in paths:
|
||||
with Image.open(path) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
image.thumbnail((128, 128), Image.Resampling.LANCZOS)
|
||||
data = image.tobytes()
|
||||
for index in range(0, len(data), 4):
|
||||
red, green, blue, alpha = data[index : index + 4]
|
||||
if alpha <= 16:
|
||||
continue
|
||||
pixels.append((red, green, blue))
|
||||
|
||||
non_background = [
|
||||
pixel
|
||||
for pixel in pixels
|
||||
if not (pixel[0] > 244 and pixel[1] > 244 and pixel[2] > 244)
|
||||
]
|
||||
return non_background or pixels
|
||||
|
||||
|
||||
def choose_chroma_key(reference_paths: list[Path], requested: str) -> dict[str, object]:
|
||||
if requested.lower() != "auto":
|
||||
rgb = parse_hex_color(requested)
|
||||
return {
|
||||
"hex": rgb_to_hex(rgb),
|
||||
"rgb": list(rgb),
|
||||
"name": "user-selected",
|
||||
"selection": "manual",
|
||||
}
|
||||
|
||||
pixels = sampled_reference_pixels(reference_paths)
|
||||
if not pixels:
|
||||
rgb = parse_hex_color("#FF00FF")
|
||||
return {
|
||||
"hex": "#FF00FF",
|
||||
"rgb": list(rgb),
|
||||
"name": "magenta",
|
||||
"selection": "fallback",
|
||||
}
|
||||
|
||||
scored: list[tuple[float, int, str, tuple[int, int, int]]] = []
|
||||
for preference_index, (name, hex_color) in enumerate(CHROMA_KEY_CANDIDATES):
|
||||
rgb = parse_hex_color(hex_color)
|
||||
distances = sorted(color_distance(rgb, pixel) for pixel in pixels)
|
||||
percentile_index = max(0, min(len(distances) - 1, int(len(distances) * 0.01)))
|
||||
scored.append((distances[percentile_index], -preference_index, name, rgb))
|
||||
|
||||
score, _preference, name, rgb = max(scored)
|
||||
return {
|
||||
"hex": rgb_to_hex(rgb),
|
||||
"rgb": list(rgb),
|
||||
"name": name,
|
||||
"selection": "auto",
|
||||
"score": round(score, 2),
|
||||
}
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text.rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def resolved_style_notes(raw_style_notes: str) -> str:
|
||||
raw_style_notes = raw_style_notes.strip()
|
||||
if not raw_style_notes:
|
||||
return DIGITAL_PET_STYLE
|
||||
return f"{DIGITAL_PET_STYLE} Additional user style notes: {raw_style_notes}."
|
||||
|
||||
|
||||
def base_pet_prompt(args: argparse.Namespace) -> str:
|
||||
pet_notes = args.pet_notes or "the pet shown in the reference image(s)"
|
||||
style_notes = resolved_style_notes(args.style_notes)
|
||||
chroma_key = args.chroma_key["hex"]
|
||||
chroma_name = args.chroma_key["name"]
|
||||
return f"""Create a single clean reference sprite for a Codex app digital pet named {args.display_name}.
|
||||
|
||||
Pet: {pet_notes}.
|
||||
Style contract: {style_notes}
|
||||
|
||||
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
|
||||
|
||||
Output one centered full-body pet sprite pose only, on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. The pet must be fully visible, readable as a tiny digital pet, and suitable for animation into a 192x208 sprite cell. Do not include scenery, text, labels, borders, checkerboard transparency, detached effects, shadows, glows, or extra props not present in the reference unless explicitly requested. Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, prop, highlights, or effects."""
|
||||
|
||||
|
||||
def row_prompt(
|
||||
args: argparse.Namespace, state: str, row: int, frames: int, purpose: str
|
||||
) -> str:
|
||||
pet_notes = args.pet_notes or "the same pet from the approved base reference"
|
||||
style_notes = resolved_style_notes(args.style_notes)
|
||||
chroma_key = args.chroma_key["hex"]
|
||||
chroma_name = args.chroma_key["name"]
|
||||
state_requirements = STATE_REQUIREMENTS.get(state, [])
|
||||
state_requirement_text = ""
|
||||
if state_requirements:
|
||||
state_requirement_text = "\n\nState-specific requirements:\n" + "\n".join(
|
||||
f"- {requirement}" for requirement in state_requirements
|
||||
)
|
||||
transparency_artifact_text = "\n".join(
|
||||
f"- {requirement}" for requirement in TRANSPARENCY_ARTIFACT_RULES
|
||||
)
|
||||
return f"""Create a single horizontal sprite strip for the Codex app digital pet `{args.pet_id}` in the state `{state}`.
|
||||
|
||||
Use the attached reference image(s) for pet identity and the attached base pet image as the canonical design. Use the attached layout guide image only for frame count, slot spacing, centering, and safe padding. Simplify any high-resolution reference details into the Codex digital pet sprite style. Do not simply copy the still reference pose. Generate distinct animation poses that create a readable cycle.
|
||||
|
||||
Identity lock:
|
||||
- Do not redesign the pet. Only change pose/action for the `{state}` animation.
|
||||
- Preserve the exact head shape, ear/horn/limb shape, face design, markings, palette, outline weight, body proportions, prop design, and overall silhouette from the canonical base pet.
|
||||
- Keep every frame recognizably the same individual pet, not a related variant.
|
||||
- If the pet has a prop or accessory, preserve its size, side, palette, and attachment style unless the row action requires a small pose-only adjustment.
|
||||
- Prefer a subtler animation over any change that mutates the pet identity.
|
||||
|
||||
Output exactly {frames} separate animation frames arranged left-to-right in one single row. Each frame must show the same pet: {pet_notes}.
|
||||
|
||||
Style contract: {style_notes}
|
||||
|
||||
Use this prompt as an authoritative sprite-production spec. Do not expand it into a polished illustration, painterly character image, anime key art, 3D render, vector mascot, glossy app icon, realistic animal portrait, or marketing artwork.
|
||||
|
||||
Animation action: {purpose}.
|
||||
{state_requirement_text}
|
||||
|
||||
Transparency and artifact rules:
|
||||
{transparency_artifact_text}
|
||||
|
||||
Layout requirements:
|
||||
- Exactly {frames} full-body frames, left to right, in one horizontal row.
|
||||
- The attached layout guide shows the {frames} frame boxes and inner safe area for this row. Follow its slot count, spacing, centering, and padding.
|
||||
- Do not reproduce the layout guide itself: no visible boxes, guide lines, center marks, labels, guide colors, or guide background may appear in the output.
|
||||
- Treat the image as {frames} equal-width invisible frame slots. Fill every slot: each requested slot must contain exactly one complete full-body pose.
|
||||
- Spread the {frames} poses evenly across the whole image width. Do not leave any requested slot blank or create large empty gaps between poses.
|
||||
- Center one complete pose in each slot. No pose may cross into the neighboring slot.
|
||||
- Use a perfectly flat pure {chroma_name} {chroma_key} chroma-key background across the whole image.
|
||||
- Do not draw visible grid lines, borders, labels, numbers, text, watermarks, or checkerboard transparency.
|
||||
- Do not include scenery or a background environment.
|
||||
- Keep the rendering sprite-like: chunky silhouette, dark pixel-style outline, limited palette, flat shading, minimal tiny detail.
|
||||
- Do not use {chroma_key}, pure {chroma_name}, or colors close to that chroma key in the pet, props, highlights, shadows, motion marks, dust, landing marks, or effects.
|
||||
- Do not draw shadows, glows, smears, dust, or landing marks using darker/lighter versions of the chroma-key color.
|
||||
- Keep every frame self-contained with safe padding. No pet body part should be clipped by the frame slot.
|
||||
- Avoid motion blur. Use clear pose changes readable at 192x208.
|
||||
- Preserve the same silhouette, face, proportions, palette, material, and props across every frame."""
|
||||
|
||||
|
||||
def make_jobs(
|
||||
run_dir: Path, copied_refs: list[dict[str, object]]
|
||||
) -> list[dict[str, object]]:
|
||||
reference_inputs = [
|
||||
{"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"}
|
||||
for ref in copied_refs
|
||||
]
|
||||
identity_reference_paths = [CANONICAL_BASE_PATH, "decoded/base.png"]
|
||||
jobs: list[dict[str, object]] = [
|
||||
{
|
||||
"id": "base",
|
||||
"kind": "base-pet",
|
||||
"status": "pending",
|
||||
"prompt_file": "prompts/base-pet.md",
|
||||
"input_images": reference_inputs,
|
||||
"output_path": "decoded/base.png",
|
||||
"depends_on": [],
|
||||
"generation_skill": "$imagegen",
|
||||
"requires_grounded_generation": bool(reference_inputs),
|
||||
"allow_prompt_only_generation": not reference_inputs,
|
||||
"recording_owner": "parent",
|
||||
}
|
||||
]
|
||||
for state, _row, frames, _purpose in ROWS:
|
||||
depends_on = ["base"]
|
||||
extra_inputs: list[dict[str, str]] = []
|
||||
mirror_policy: dict[str, object] = {}
|
||||
if state == "running-left":
|
||||
depends_on.append("running-right")
|
||||
extra_inputs.append(
|
||||
{
|
||||
"path": "decoded/running-right.png",
|
||||
"role": "rightward gait reference for leftward row decision",
|
||||
}
|
||||
)
|
||||
mirror_policy = {
|
||||
"may_derive_from": "running-right",
|
||||
"derivation": "horizontal-mirror",
|
||||
"requires_explicit_approval": True,
|
||||
"fallback_generation_skill": "$imagegen",
|
||||
}
|
||||
jobs.append(
|
||||
{
|
||||
"id": state,
|
||||
"kind": "row-strip",
|
||||
"status": "pending",
|
||||
"prompt_file": f"prompts/rows/{state}.md",
|
||||
"input_images": [
|
||||
*reference_inputs,
|
||||
{
|
||||
"path": f"{LAYOUT_GUIDE_DIR}/{state}.png",
|
||||
"role": f"layout guide for {frames} frame slots; use for spacing only, do not copy guide lines",
|
||||
},
|
||||
{
|
||||
"path": CANONICAL_BASE_PATH,
|
||||
"role": "canonical identity reference",
|
||||
},
|
||||
{"path": "decoded/base.png", "role": "approved base pet"},
|
||||
*extra_inputs,
|
||||
],
|
||||
"output_path": f"decoded/{state}.png",
|
||||
"depends_on": depends_on,
|
||||
"generation_skill": "$imagegen",
|
||||
"requires_grounded_generation": True,
|
||||
"allow_prompt_only_generation": False,
|
||||
"identity_reference_paths": identity_reference_paths,
|
||||
"parallelizable_after": depends_on,
|
||||
"mirror_policy": mirror_policy,
|
||||
"recording_owner": "parent",
|
||||
}
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--pet-name",
|
||||
default="",
|
||||
help="User-facing pet name. Ask the user for this when practical; otherwise choose a short appropriate name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pet-id",
|
||||
default="",
|
||||
help="Stable pet folder/id slug. Defaults to the slugified pet name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--display-name",
|
||||
default="",
|
||||
help="Display label. Defaults to the pet name.",
|
||||
)
|
||||
parser.add_argument("--description", default="")
|
||||
parser.add_argument("--reference", action="append", default=[])
|
||||
parser.add_argument("--output-dir", default="")
|
||||
parser.add_argument("--pet-notes", default="")
|
||||
parser.add_argument("--style-notes", default="")
|
||||
parser.add_argument(
|
||||
"--chroma-key",
|
||||
default="auto",
|
||||
help="Chroma key as #RRGGBB, or auto to choose a safe key from reference colors.",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
raw_reference_paths = [
|
||||
Path(raw_path).expanduser().resolve() for raw_path in args.reference
|
||||
]
|
||||
|
||||
args.display_name = infer_name(args, raw_reference_paths)
|
||||
args.pet_name = (args.pet_name or args.display_name).strip()
|
||||
args.description = infer_description(args, raw_reference_paths)
|
||||
args.pet_notes = infer_pet_notes(args, raw_reference_paths)
|
||||
args.pet_id = slugify(args.pet_id or args.pet_name or args.display_name)
|
||||
if not args.pet_id:
|
||||
raise SystemExit("pet id must contain at least one letter or digit")
|
||||
|
||||
run_dir = (
|
||||
Path(args.output_dir).expanduser().resolve()
|
||||
if args.output_dir
|
||||
else default_output_dir(args.pet_id).resolve()
|
||||
)
|
||||
if run_dir.exists() and any(run_dir.iterdir()) and not args.force:
|
||||
raise SystemExit(
|
||||
f"{run_dir} already exists and is not empty; pass --force to reuse it"
|
||||
)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ref_dir = run_dir / "references"
|
||||
prompt_dir = run_dir / "prompts"
|
||||
row_prompt_dir = prompt_dir / "rows"
|
||||
for directory in [
|
||||
ref_dir,
|
||||
prompt_dir,
|
||||
row_prompt_dir,
|
||||
run_dir / "decoded",
|
||||
run_dir / "qa",
|
||||
]:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied_refs: list[dict[str, object]] = []
|
||||
copied_ref_paths: list[Path] = []
|
||||
for index, source in enumerate(raw_reference_paths, start=1):
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"reference not found: {source}")
|
||||
suffix = source.suffix.lower() or ".png"
|
||||
copied = ref_dir / f"reference-{index:02d}{suffix}"
|
||||
shutil.copy2(source, copied)
|
||||
meta = image_metadata(copied)
|
||||
meta["source_path"] = str(source)
|
||||
meta["copied_path"] = str(copied)
|
||||
copied_refs.append(meta)
|
||||
copied_ref_paths.append(copied)
|
||||
|
||||
args.chroma_key = choose_chroma_key(copied_ref_paths, args.chroma_key)
|
||||
layout_guides = create_layout_guides(run_dir)
|
||||
|
||||
request = {
|
||||
"pet_id": args.pet_id,
|
||||
"display_name": args.display_name,
|
||||
"description": args.description,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"atlas": ATLAS,
|
||||
"rows": [
|
||||
{"state": state, "row": row, "frames": frames, "purpose": purpose}
|
||||
for state, row, frames, purpose in ROWS
|
||||
],
|
||||
"layout_guides": [
|
||||
{**guide, "path": rel(Path(str(guide["path"])), run_dir)}
|
||||
for guide in layout_guides
|
||||
],
|
||||
"references": copied_refs,
|
||||
"chroma_key": args.chroma_key,
|
||||
"pet_notes": args.pet_notes,
|
||||
"style_notes": args.style_notes,
|
||||
"house_style": DIGITAL_PET_STYLE,
|
||||
"primary_generation_skill": "$imagegen",
|
||||
}
|
||||
(run_dir / "pet_request.json").write_text(
|
||||
json.dumps(request, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
write_text(prompt_dir / "base-pet.md", base_pet_prompt(args))
|
||||
for state, row, frames, purpose in ROWS:
|
||||
write_text(
|
||||
row_prompt_dir / f"{state}.md",
|
||||
row_prompt(args, state, row, frames, purpose),
|
||||
)
|
||||
|
||||
jobs = {
|
||||
"schema_version": 1,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"run_dir": str(run_dir),
|
||||
"primary_generation_skill": "$imagegen",
|
||||
"jobs": make_jobs(run_dir, copied_refs),
|
||||
}
|
||||
(run_dir / "imagegen-jobs.json").write_text(
|
||||
json.dumps(jobs, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"run_dir": str(run_dir),
|
||||
"request": str(run_dir / "pet_request.json"),
|
||||
"jobs": str(run_dir / "imagegen-jobs.json"),
|
||||
"ready_jobs": ["base"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reopen failed Codex pet row jobs after frame QA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"file not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def rows_to_repair(
|
||||
review: dict[str, object], *, repair_on_warnings: bool
|
||||
) -> list[dict[str, object]]:
|
||||
rows = review.get("rows")
|
||||
if not isinstance(rows, list):
|
||||
raise SystemExit("review does not contain row-level results")
|
||||
|
||||
repairs: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or not isinstance(row.get("state"), str):
|
||||
continue
|
||||
errors = row.get("errors") if isinstance(row.get("errors"), list) else []
|
||||
warnings = row.get("warnings") if isinstance(row.get("warnings"), list) else []
|
||||
if errors or (repair_on_warnings and warnings):
|
||||
repairs.append(
|
||||
{
|
||||
"state": row["state"],
|
||||
"reason": "; ".join(str(item) for item in [*errors, *warnings])
|
||||
or "the row did not pass visual QA",
|
||||
}
|
||||
)
|
||||
return repairs
|
||||
|
||||
|
||||
def append_repair_note(run_dir: Path, state: str, attempt: int, reason: str) -> None:
|
||||
prompt_path = run_dir / "prompts" / "rows" / f"{state}.md"
|
||||
if not prompt_path.exists():
|
||||
raise SystemExit(f"row prompt not found: {prompt_path}")
|
||||
existing = prompt_path.read_text(encoding="utf-8")
|
||||
note = f"""
|
||||
|
||||
Repair attempt {attempt}:
|
||||
- The previous `{state}` strip failed QA: {reason}
|
||||
- Regenerate the entire row, not just one pose.
|
||||
- Fill every requested frame slot with one complete centered full-body pet pose.
|
||||
- Keep large gaps of pure chroma key only between slots; do not leave a requested slot empty.
|
||||
- Avoid pose overlap, clipping, edge slivers, extra partial sprites, and detached fragments from neighboring poses.
|
||||
- Use the canonical base image and any original references listed in `imagegen-jobs.json` as grounding inputs.
|
||||
- Do not redesign the pet. Keep the exact same head shape, face design, markings, body proportions, palette, outline weight, materials, and props as the approved base pet.
|
||||
- If the contact sheet shows identity drift, repair only this row while preserving the canonical base identity.
|
||||
"""
|
||||
prompt_path.write_text(existing.rstrip() + note.rstrip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def next_archive_path(archive_dir: Path, state: str, attempt: int, suffix: str) -> Path:
|
||||
candidate = archive_dir / f"{state}-attempt-{attempt}-previous{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = archive_dir / f"{state}-attempt-{attempt}-previous-{counter}{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def archive_decoded_output(run_dir: Path, job: dict[str, object], state: str, attempt: int) -> str | None:
|
||||
output_raw = job.get("output_path")
|
||||
output = (
|
||||
run_dir / output_raw
|
||||
if isinstance(output_raw, str) and output_raw
|
||||
else run_dir / "decoded" / f"{state}.png"
|
||||
)
|
||||
if not output.exists():
|
||||
return None
|
||||
archive_dir = run_dir / "decoded" / "repair-archive"
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
archived = next_archive_path(archive_dir, state, attempt, output.suffix or ".png")
|
||||
shutil.move(str(output), archived)
|
||||
return str(archived.relative_to(run_dir))
|
||||
|
||||
|
||||
def queue_repair(manifest: dict[str, object], run_dir: Path, state: str, reason: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") != state:
|
||||
continue
|
||||
attempt = int(job.get("repair_attempt", 0)) + 1
|
||||
archived_output = archive_decoded_output(run_dir, job, state, attempt)
|
||||
job["status"] = "pending"
|
||||
job["repair_attempt"] = attempt
|
||||
job["repair_reason"] = reason
|
||||
job["queued_at"] = datetime.now(timezone.utc).isoformat()
|
||||
if archived_output is not None:
|
||||
previous_outputs = job.setdefault("previous_outputs", [])
|
||||
if not isinstance(previous_outputs, list):
|
||||
previous_outputs = []
|
||||
job["previous_outputs"] = previous_outputs
|
||||
previous_outputs.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"path": archived_output,
|
||||
"archived_at": job["queued_at"],
|
||||
}
|
||||
)
|
||||
for key in [
|
||||
"source_path",
|
||||
"source_provenance",
|
||||
"source_sha256",
|
||||
"output_sha256",
|
||||
"completed_at",
|
||||
"metadata",
|
||||
"synthetic_test_source",
|
||||
"secondary_fallback",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
result: dict[str, object] = {"attempt": attempt}
|
||||
if archived_output is not None:
|
||||
result["archived_output"] = archived_output
|
||||
return result
|
||||
raise SystemExit(f"unknown row job id: {state}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--review", default="")
|
||||
parser.add_argument("--repair-on-warnings", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
review_path = (
|
||||
Path(args.review).expanduser().resolve()
|
||||
if args.review
|
||||
else run_dir / "qa" / "review.json"
|
||||
)
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
review = load_json(review_path)
|
||||
manifest = load_json(manifest_path)
|
||||
|
||||
repairs = rows_to_repair(review, repair_on_warnings=args.repair_on_warnings)
|
||||
queued: list[dict[str, object]] = []
|
||||
for repair in repairs:
|
||||
state = str(repair["state"])
|
||||
reason = str(repair["reason"])
|
||||
queued_repair = queue_repair(manifest, run_dir, state, reason)
|
||||
attempt = int(queued_repair["attempt"])
|
||||
append_repair_note(run_dir, state, attempt, reason)
|
||||
queued.append({"state": state, "reason": reason, **queued_repair})
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"ok": True, "queued": queued}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record a selected $imagegen output for a Codex pet generation job."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
CANONICAL_BASE_PATH = "references/canonical-base.png"
|
||||
|
||||
|
||||
def load_jobs(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"job manifest not found: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
|
||||
jobs = manifest.get("jobs")
|
||||
if not isinstance(jobs, list):
|
||||
raise SystemExit("invalid imagegen-jobs.json: jobs must be a list")
|
||||
return [job for job in jobs if isinstance(job, dict)]
|
||||
|
||||
|
||||
def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
|
||||
for job in job_list(manifest):
|
||||
if job.get("id") == job_id:
|
||||
return job
|
||||
raise SystemExit(f"unknown job id: {job_id}")
|
||||
|
||||
|
||||
def image_metadata(path: Path) -> dict[str, object]:
|
||||
with Image.open(path) as image:
|
||||
image.verify()
|
||||
with Image.open(path) as image:
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"mode": image.mode,
|
||||
"format": image.format,
|
||||
}
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def manifest_relative(path: Path, run_dir: Path) -> str:
|
||||
return str(path.resolve().relative_to(run_dir.resolve()))
|
||||
|
||||
|
||||
def completed_job_ids(manifest: dict[str, object]) -> set[str]:
|
||||
return {
|
||||
str(job["id"])
|
||||
for job in job_list(manifest)
|
||||
if job.get("status") == "complete" and isinstance(job.get("id"), str)
|
||||
}
|
||||
|
||||
|
||||
def is_relative_to(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def default_generated_images_root() -> Path:
|
||||
codex_home = Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve()
|
||||
return codex_home / "generated_images"
|
||||
|
||||
|
||||
def validate_source_path(
|
||||
*,
|
||||
source: Path,
|
||||
run_dir: Path,
|
||||
allow_synthetic_test_source: bool,
|
||||
) -> str:
|
||||
if allow_synthetic_test_source:
|
||||
return "synthetic-test"
|
||||
if is_relative_to(source, run_dir):
|
||||
raise SystemExit(
|
||||
"source image is inside the pet run directory; record the original "
|
||||
"$imagegen output from $CODEX_HOME/generated_images/.../ig_*.png instead"
|
||||
)
|
||||
generated_root = default_generated_images_root()
|
||||
if not is_relative_to(source, generated_root) or not source.name.startswith("ig_"):
|
||||
raise SystemExit(
|
||||
"source image does not look like a built-in $imagegen output; expected "
|
||||
f"{generated_root}/.../ig_*.png. Do not ingest locally drawn or "
|
||||
"post-processed row strips as visual job outputs."
|
||||
)
|
||||
return "built-in-imagegen"
|
||||
|
||||
|
||||
def validate_required_grounding(job: dict[str, object], run_dir: Path) -> None:
|
||||
if job.get("allow_prompt_only_generation") is not False:
|
||||
return
|
||||
inputs = job.get("input_images")
|
||||
if not isinstance(inputs, list) or not inputs:
|
||||
raise SystemExit(
|
||||
f"job {job.get('id')} does not list input_images; grounded row jobs must attach references"
|
||||
)
|
||||
missing = []
|
||||
for item in inputs:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
||||
raise SystemExit(f"job {job.get('id')} has an invalid input image entry")
|
||||
path = run_dir / item["path"]
|
||||
if not path.is_file():
|
||||
missing.append(str(path))
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
f"job {job.get('id')} is missing required grounding image(s): "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def update_base_canonical_reference(
|
||||
*,
|
||||
run_dir: Path,
|
||||
output: Path,
|
||||
manifest: dict[str, object],
|
||||
job: dict[str, object],
|
||||
metadata: dict[str, object],
|
||||
) -> None:
|
||||
if job.get("id") != "base":
|
||||
return
|
||||
|
||||
canonical = run_dir / CANONICAL_BASE_PATH
|
||||
canonical.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(output, canonical)
|
||||
canonical_sha = file_sha256(canonical)
|
||||
reference = {
|
||||
"path": manifest_relative(canonical, run_dir),
|
||||
"source_job": "base",
|
||||
"sha256": canonical_sha,
|
||||
"metadata": metadata,
|
||||
}
|
||||
job["canonical_reference_path"] = reference["path"]
|
||||
manifest["canonical_identity_reference"] = reference
|
||||
|
||||
request_path = run_dir / "pet_request.json"
|
||||
if request_path.exists():
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
request["canonical_identity_reference"] = reference
|
||||
request_path.write_text(json.dumps(request, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--run-dir", required=True)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--source", required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
parser.add_argument(
|
||||
"--allow-synthetic-test-source", action="store_true", help=argparse.SUPPRESS
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
run_dir = Path(args.run_dir).expanduser().resolve()
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"source image not found: {source}")
|
||||
source_provenance = validate_source_path(
|
||||
source=source,
|
||||
run_dir=run_dir,
|
||||
allow_synthetic_test_source=args.allow_synthetic_test_source,
|
||||
)
|
||||
|
||||
manifest_path = run_dir / "imagegen-jobs.json"
|
||||
manifest = load_jobs(manifest_path)
|
||||
job = find_job(manifest, args.job_id)
|
||||
|
||||
missing_deps = [
|
||||
dep
|
||||
for dep in job.get("depends_on", [])
|
||||
if isinstance(dep, str) and dep not in completed_job_ids(manifest)
|
||||
]
|
||||
if missing_deps:
|
||||
raise SystemExit(
|
||||
f"job {args.job_id} is not ready; missing dependency result(s): {', '.join(missing_deps)}"
|
||||
)
|
||||
validate_required_grounding(job, run_dir)
|
||||
|
||||
output_raw = job.get("output_path")
|
||||
if not isinstance(output_raw, str):
|
||||
raise SystemExit(f"job {args.job_id} has no output_path")
|
||||
output = run_dir / output_raw
|
||||
if output.exists() and not args.force:
|
||||
raise SystemExit(f"{output} already exists; pass --force to replace it")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, output)
|
||||
metadata = image_metadata(output)
|
||||
|
||||
job["status"] = "complete"
|
||||
job["source_path"] = str(source)
|
||||
job["source_provenance"] = source_provenance
|
||||
job["source_sha256"] = file_sha256(source)
|
||||
job["output_sha256"] = file_sha256(output)
|
||||
if source_provenance == "synthetic-test":
|
||||
job["synthetic_test_source"] = True
|
||||
else:
|
||||
job.pop("synthetic_test_source", None)
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
job["metadata"] = metadata
|
||||
for key in [
|
||||
"last_error",
|
||||
"secondary_fallback",
|
||||
"derived_from",
|
||||
"mirror_decision",
|
||||
"repair_reason",
|
||||
"queued_at",
|
||||
]:
|
||||
job.pop(key, None)
|
||||
update_base_canonical_reference(
|
||||
run_dir=run_dir,
|
||||
output=output,
|
||||
manifest=manifest,
|
||||
job=job,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"job_id": args.job_id,
|
||||
"output": str(output),
|
||||
"metadata": metadata,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Codex pet state videos from an atlas using ffmpeg."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
STATES = {
|
||||
"idle": (0, [280, 110, 110, 140, 140, 320]),
|
||||
"running-right": (1, [120, 120, 120, 120, 120, 120, 120, 220]),
|
||||
"running-left": (2, [120, 120, 120, 120, 120, 120, 120, 220]),
|
||||
"waving": (3, [140, 140, 140, 280]),
|
||||
"jumping": (4, [140, 140, 140, 140, 280]),
|
||||
"failed": (5, [140, 140, 140, 140, 140, 140, 140, 240]),
|
||||
"waiting": (6, [150, 150, 150, 150, 150, 260]),
|
||||
"running": (7, [120, 120, 120, 120, 120, 220]),
|
||||
"review": (8, [150, 150, 150, 150, 150, 280]),
|
||||
}
|
||||
|
||||
|
||||
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
|
||||
image = Image.new("RGB", size, "#ffffff")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for y in range(0, size[1], square):
|
||||
for x in range(0, size[0], square):
|
||||
if (x // square + y // square) % 2:
|
||||
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
|
||||
return image
|
||||
|
||||
|
||||
def shell_quote_for_concat(path: Path) -> str:
|
||||
return "'" + str(path).replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def render_state(
|
||||
atlas: Image.Image,
|
||||
state: str,
|
||||
row: int,
|
||||
durations: list[int],
|
||||
output_dir: Path,
|
||||
loops: int,
|
||||
scale: int,
|
||||
ffmpeg: str,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix=f"codex-pet-{state}-") as temp_raw:
|
||||
temp = Path(temp_raw)
|
||||
frame_paths: list[Path] = []
|
||||
for column in range(len(durations)):
|
||||
crop = atlas.crop(
|
||||
(
|
||||
column * CELL_WIDTH,
|
||||
row * CELL_HEIGHT,
|
||||
(column + 1) * CELL_WIDTH,
|
||||
(row + 1) * CELL_HEIGHT,
|
||||
)
|
||||
).convert("RGBA")
|
||||
bg = checker((CELL_WIDTH, CELL_HEIGHT))
|
||||
bg.paste(crop, (0, 0), crop)
|
||||
frame_path = temp / f"{state}-{column:02d}.png"
|
||||
bg.save(frame_path)
|
||||
frame_paths.append(frame_path)
|
||||
|
||||
concat_path = temp / f"{state}.ffconcat"
|
||||
lines = ["ffconcat version 1.0"]
|
||||
sequence: list[tuple[Path, int]] = []
|
||||
for _ in range(loops):
|
||||
sequence.extend(zip(frame_paths, durations, strict=True))
|
||||
for frame_path, duration_ms in sequence:
|
||||
lines.append(f"file {shell_quote_for_concat(frame_path)}")
|
||||
lines.append(f"duration {duration_ms / 1000:.3f}")
|
||||
lines.append(f"file {shell_quote_for_concat(sequence[-1][0])}")
|
||||
concat_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
output = output_dir / f"{state}.mp4"
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(concat_path),
|
||||
"-vf",
|
||||
f"scale={CELL_WIDTH * scale}:{CELL_HEIGHT * scale}:flags=lanczos,format=yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output),
|
||||
]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--output-dir", required=True)
|
||||
parser.add_argument("--loops", type=int, default=4)
|
||||
parser.add_argument("--scale", type=int, default=2)
|
||||
parser.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg")
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
|
||||
for state, (row, durations) in STATES.items():
|
||||
render_state(
|
||||
atlas,
|
||||
state,
|
||||
row,
|
||||
durations,
|
||||
output_dir,
|
||||
args.loops,
|
||||
args.scale,
|
||||
args.ffmpeg,
|
||||
)
|
||||
print(f"wrote videos to {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
python3 "$SCRIPT_DIR/render_animation_videos.py" "$@"
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Codex pet spritesheet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
ROW_BY_INDEX = {
|
||||
0: ("idle", 6),
|
||||
1: ("running-right", 8),
|
||||
2: ("running-left", 8),
|
||||
3: ("waving", 4),
|
||||
4: ("jumping", 5),
|
||||
5: ("failed", 8),
|
||||
6: ("waiting", 6),
|
||||
7: ("running", 6),
|
||||
8: ("review", 6),
|
||||
}
|
||||
|
||||
|
||||
def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--json-out")
|
||||
parser.add_argument("--min-used-pixels", type=int, default=50)
|
||||
parser.add_argument("--near-opaque-threshold", type=float, default=0.95)
|
||||
parser.add_argument("--allow-opaque", action="store_true")
|
||||
parser.add_argument("--allow-near-opaque-used-cells", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
atlas_path = Path(args.atlas).expanduser().resolve()
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
near_opaque_used_cells: dict[str, list[int]] = defaultdict(list)
|
||||
cells: list[dict[str, object]] = []
|
||||
|
||||
try:
|
||||
with Image.open(atlas_path) as opened:
|
||||
source_mode = opened.mode
|
||||
source_format = opened.format
|
||||
image = opened.convert("RGBA")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []}
|
||||
print(json.dumps(result, indent=2))
|
||||
raise SystemExit(1)
|
||||
|
||||
if image.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {image.width}x{image.height}")
|
||||
|
||||
if source_format not in {"PNG", "WEBP"}:
|
||||
errors.append(f"expected PNG or WebP, got {source_format}")
|
||||
|
||||
if "A" not in source_mode and not args.allow_opaque:
|
||||
errors.append("atlas does not have an alpha channel")
|
||||
|
||||
for row_index in range(ROWS):
|
||||
state, frame_count = ROW_BY_INDEX[row_index]
|
||||
for column_index in range(COLUMNS):
|
||||
left = column_index * CELL_WIDTH
|
||||
top = row_index * CELL_HEIGHT
|
||||
cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
nontransparent = alpha_nonzero_count(cell)
|
||||
used = column_index < frame_count
|
||||
cell_info = {
|
||||
"state": state,
|
||||
"row": row_index,
|
||||
"column": column_index,
|
||||
"used": used,
|
||||
"nontransparent_pixels": nontransparent,
|
||||
}
|
||||
cells.append(cell_info)
|
||||
if used and nontransparent < args.min_used_pixels:
|
||||
errors.append(
|
||||
f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)"
|
||||
)
|
||||
if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold:
|
||||
near_opaque_used_cells[f"{state} row {row_index}"].append(column_index)
|
||||
if not used and nontransparent != 0:
|
||||
errors.append(
|
||||
f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)"
|
||||
)
|
||||
|
||||
for row_label, columns in near_opaque_used_cells.items():
|
||||
message = (
|
||||
f"{row_label} has {len(columns)} nearly opaque used cells; "
|
||||
"this usually means the sprite has a non-transparent background"
|
||||
)
|
||||
if args.allow_near_opaque_used_cells:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
alpha_count = alpha_nonzero_count(image)
|
||||
if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT:
|
||||
message = "atlas is fully opaque; custom pets require a transparent sprite background"
|
||||
if args.allow_opaque:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"file": str(atlas_path),
|
||||
"format": source_format,
|
||||
"mode": source_mode,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"cells": cells,
|
||||
}
|
||||
|
||||
if args.json_out:
|
||||
Path(args.json_out).expanduser().resolve().write_text(
|
||||
json.dumps(result, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user