Files
agent-pet-companion/skills/agent-pet-studio/scripts/write_petpack_source.py
T

327 lines
11 KiB
Python

#!/usr/bin/env python3
"""Write a validated Agent Pet Companion petpack-source for App Server turns.
This helper is intentionally self-contained so the Codex App Server can invoke a
small, auditable command instead of hand-authoring every petpack file.
"""
import base64
import binascii
import json
import os
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
import time
import zlib
HELPER_ID = "agent-pet-studio-preview-helper-v2"
FRAMES_PER_STATE = 12
FORM_PATH = Path("apc_skill_form.json")
OUTPUT_DIR = Path("petpack-source")
STATES = ["idle", "start", "tool", "waiting", "review", "done", "failed"]
RENDER_SIZES = {
"standard": (192, 208),
"high": (384, 416),
"ultra": (768, 832),
"original": (1536, 1664),
}
STATE_COLORS = {
"idle": (96, 169, 232),
"start": (129, 81, 247),
"tool": (60, 189, 214),
"waiting": (240, 176, 64),
"review": (116, 113, 255),
"done": (64, 196, 129),
"failed": (232, 90, 110),
}
TINY_WEBP_BASE64 = "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA"
def now_rfc3339():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def load_form():
if not FORM_PATH.is_file():
raise SystemExit(f"missing {FORM_PATH}")
return json.loads(FORM_PATH.read_text(encoding="utf-8"))
def safe_pet_id(description):
text = re.sub(r"[^a-z0-9]+", "", str(description).lower())
suffix = text[:10] or "skillpet"
millis = int(time.time() * 1000) % 100000000
return f"pet_{suffix}{millis}"
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", binascii.crc32(kind + data) & 0xFFFFFFFF)
)
def write_png(path, width, height, state_name, frame_index=0):
base = STATE_COLORS[state_name]
cx = width // 2
body_cy = int(height * 0.58)
body_rx = max(10, int(width * 0.24))
body_ry = max(12, int(height * 0.31))
head_cy = int(height * 0.29)
head_rx = max(8, int(width * 0.15))
head_ry = max(8, int(height * 0.12))
accent_cy = int(height * 0.64)
accent_rx = max(8, int(width * 0.17))
accent_ry = max(4, int(height * 0.05))
body_limit = body_rx * body_rx * body_ry * body_ry
head_limit = head_rx * head_rx * head_ry * head_ry
accent_limit = accent_rx * accent_rx * accent_ry * accent_ry
rows = []
phase = frame_index % 2
for y in range(height):
row = bytearray([0])
for x in range(width):
r = g = b = a = 0
dx = x - cx
dy_body = y - body_cy
dy_head = y - head_cy
in_body = dx * dx * body_ry * body_ry + dy_body * dy_body * body_rx * body_rx <= body_limit
in_head = dx * dx * head_ry * head_ry + dy_head * dy_head * head_rx * head_rx <= head_limit
in_accent = dx * dx * accent_ry * accent_ry + (y - accent_cy) * (y - accent_cy) * accent_rx * accent_rx <= accent_limit
if in_body:
shade = max(0, min(42, int((y - body_cy) * 42 / max(1, body_ry))))
r, g, b, a = max(0, base[0] - shade), max(0, base[1] - shade), max(0, base[2] - shade), 222
if in_head:
r, g, b, a = 255, 205, 176, 255
if in_accent:
r, g, b, a = 86, 184, 224, 230
wing_left = int(width * 0.24) - phase <= x <= int(width * 0.42) and int(height * 0.54) <= y <= int(height * 0.83)
wing_right = int(width * 0.58) <= x <= int(width * 0.76) + phase and int(height * 0.54) <= y <= int(height * 0.83)
if wing_left and abs((x - int(width * 0.42)) * 2) + abs(y - int(height * 0.70)) < int(width * 0.22):
r, g, b, a = 255, 193, 205, 150
if wing_right and abs((x - int(width * 0.58)) * 2) + abs(y - int(height * 0.70)) < int(width * 0.22):
r, g, b, a = 255, 193, 205, 150
if in_head and y > head_cy and abs(dx) < max(2, width // 35):
r, g, b, a = 42, 42, 42, 255
row.extend((r, g, b, a))
rows.append(bytes(row))
raw = b"".join(rows)
data = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b"")
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def write_text(path, text):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def build_source(form):
quality = str(form.get("quality") or "standard")
width, height = RENDER_SIZES.get(quality, RENDER_SIZES["standard"])
created_at = now_rfc3339()
pet_id = safe_pet_id(form.get("description", "skillpet"))
name = "Skill Studio Pet"
style = str(form.get("style") or "不指定")
manifest = {
"schema_version": "apc.petpack.v1",
"id": pet_id,
"name": name,
"style": style,
"quality": quality if quality in RENDER_SIZES else "standard",
"render_size": {"width": width, "height": height},
"fps_profiles": {"standard": 12, "smooth": 20},
"default_fps_profile": "standard",
"states": [
{
"name": state,
"frames_dir": f"assets/frames/{state}",
"loop": state not in {"start", "done"},
}
for state in STATES
],
"created_at": created_at,
}
write_json(OUTPUT_DIR / "manifest.json", manifest)
write_json(
OUTPUT_DIR / "brief.json",
{
"schema_version": "apc.pet-brief.v1",
"name": name,
"style": style,
"quality": quality,
"description": form.get("description"),
"states": [
{"name": state, "motion": f"{state} validation motion from App Server Skill helper"}
for state in STATES
],
"generation": {
"generator": "agent-pet-studio-preview-helper",
"provenance": "deterministic_preview",
"skill_helper": HELPER_ID,
"preview_only": True,
},
},
)
for state in STATES:
for frame_index in range(FRAMES_PER_STATE):
write_png(
OUTPUT_DIR / "assets" / "frames" / state / f"{frame_index:04d}.png",
width,
height,
state,
frame_index,
)
write_png(OUTPUT_DIR / "assets" / "preview" / "cover.png", 384, 416, "idle")
(OUTPUT_DIR / "assets" / "preview").mkdir(parents=True, exist_ok=True)
(OUTPUT_DIR / "assets" / "preview" / "animated_preview.webp").write_bytes(
base64.b64decode(TINY_WEBP_BASE64 + "=" * ((4 - len(TINY_WEBP_BASE64) % 4) % 4))
)
source_dir = OUTPUT_DIR / "source"
references_dir = source_dir / "references"
references_dir.mkdir(parents=True, exist_ok=True)
reference_files = []
for index, raw_path in enumerate(form.get("reference_images") or []):
reference_path = Path(str(raw_path))
suffix = reference_path.suffix.lower()
if suffix not in {".png", ".webp"} or not reference_path.is_file():
continue
copied_name = f"reference-{index + 1}{suffix}"
shutil.copyfile(reference_path, references_dir / copied_name)
reference_files.append(copied_name)
packaged_form = dict(form)
packaged_form["reference_images"] = reference_files
write_text(
source_dir / "prompt.md",
"# Agent Pet Studio Prompt\n\n"
+ json.dumps(packaged_form, ensure_ascii=False, indent=2)
+ "\n",
)
write_json(
source_dir / "source.json",
{
"schema_version": "apc.pet-source.v1",
"generator": "agent-pet-studio-preview-helper",
"provenance": "deterministic_preview",
"skill_helper": HELPER_ID,
"runner": "codex-app-server",
"created_at": created_at,
"form": packaged_form,
"reference_files": reference_files,
"frames_per_state": FRAMES_PER_STATE,
"visual_source": "deterministic-preview",
"preview_only": True,
"reference_visual_influence": False,
},
)
write_text(
source_dir / "skill_session.jsonl",
json.dumps(
{
"schema_version": "apc.pet-source-event.v1",
"event": "skill.loaded",
"skill": "agent-pet-studio",
"runner": "codex-app-server",
"helper": HELPER_ID,
"created_at": created_at,
},
ensure_ascii=False,
)
+ "\n"
+ json.dumps(
{
"schema_version": "apc.pet-source-event.v1",
"event": "skill.petpack_source.written",
"petpack_source": "petpack-source",
"runner": "codex-app-server",
"helper": HELPER_ID,
"created_at": now_rfc3339(),
},
ensure_ascii=False,
)
+ "\n",
)
write_json(
OUTPUT_DIR / "build" / "validation.json",
{
"schema_version": "apc.pet-validation.v1",
"ok": True,
"skill_helper": HELPER_ID,
"preview_only": True,
"frames_per_state": FRAMES_PER_STATE,
},
)
return manifest
def validate_source():
cli = os.environ.get("APC_PETCORE_CLI")
if not cli:
return {"ok": True, "skipped": "APC_PETCORE_CLI is unset"}
result = subprocess.run(
[cli, "petpack", "validate", str(OUTPUT_DIR)],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=60,
check=False,
)
if result.returncode != 0:
write_json(
OUTPUT_DIR / "build" / "validation.json",
{
"ok": False,
"skill_helper": HELPER_ID,
"stderr": result.stderr,
"stdout": result.stdout,
},
)
raise SystemExit(result.stderr or result.stdout or "petpack validate failed")
validation = json.loads(result.stdout)
validation["schema_version"] = "apc.pet-validation.v1"
validation["skill_helper"] = HELPER_ID
write_json(OUTPUT_DIR / "build" / "validation.json", validation)
return validation
def main():
form = load_form()
manifest = build_source(form)
validation = validate_source()
print(
json.dumps(
{
"petpack_source": "petpack-source",
"mode": "deterministic_preview",
"skill_helper": HELPER_ID,
"manifest_id": manifest["id"],
"validated": validation.get("ok") is True,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()