@howaboua/pi-pet 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -0
  4. package/SECURITY.md +24 -0
  5. package/authoring/PET-GUIDE.md +65 -0
  6. package/authoring/hatch/HATCH-GUIDE.md +136 -0
  7. package/authoring/hatch/LICENSE.txt +201 -0
  8. package/authoring/hatch/NOTICE.md +5 -0
  9. package/authoring/hatch/pyproject.toml +8 -0
  10. package/authoring/hatch/references/animation-rows.md +29 -0
  11. package/authoring/hatch/references/final-assembly.md +67 -0
  12. package/authoring/hatch/references/look-directions.md +35 -0
  13. package/authoring/hatch/references/pet-contract.md +40 -0
  14. package/authoring/hatch/references/qa-rubric.md +34 -0
  15. package/authoring/hatch/references/reviewer-protocol.md +72 -0
  16. package/authoring/hatch/references/standard-assembly.md +21 -0
  17. package/authoring/hatch/references/visual-generation.md +59 -0
  18. package/authoring/hatch/scripts/approve_cardinals.py +110 -0
  19. package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
  20. package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
  21. package/authoring/hatch/scripts/compose_atlas.py +171 -0
  22. package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
  23. package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
  24. package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
  25. package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
  26. package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
  27. package/authoring/hatch/scripts/inspect_frames.py +278 -0
  28. package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
  29. package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
  30. package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
  31. package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
  32. package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
  33. package/authoring/hatch/scripts/render_animation_previews.py +80 -0
  34. package/authoring/hatch/scripts/validate_atlas.py +299 -0
  35. package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
  36. package/authoring/hatch/uv.lock +99 -0
  37. package/authoring/references/import-existing-pet.md +47 -0
  38. package/authoring/references/pet-format.md +88 -0
  39. package/changelog.ts +327 -0
  40. package/deploy/pi-pet-hyprland.conf +10 -0
  41. package/desktop/build.mjs +194 -0
  42. package/desktop/package-lock.json +887 -0
  43. package/desktop/package.json +16 -0
  44. package/dist/authoring/prepare.mjs +3 -0
  45. package/dist/authoring/rebuild.mjs +4 -0
  46. package/dist/authoring/validate.mjs +2 -0
  47. package/dist/desktop/main.mjs +5 -0
  48. package/dist/desktop/preload.cjs +1 -0
  49. package/dist/web/app.js +1 -0
  50. package/dist/web/catalog.json +1 -0
  51. package/dist/web/index.html +100 -0
  52. package/dist/web/manifest.webmanifest +16 -0
  53. package/dist/web/pet-icon.svg +6 -0
  54. package/dist/web/spritesheet.webp +0 -0
  55. package/dist/web/styles.css +592 -0
  56. package/docs/architecture.md +41 -0
  57. package/docs/pi-pet.png +0 -0
  58. package/docs/research.md +17 -0
  59. package/extensions/desktop-command.ts +249 -0
  60. package/extensions/desktop-config.ts +152 -0
  61. package/extensions/desktop-launcher.ts +380 -0
  62. package/extensions/gippity.ts +24 -0
  63. package/extensions/index.ts +110 -0
  64. package/extensions/remote-app.ts +61 -0
  65. package/package.json +101 -0
  66. package/pets/clawa/PET.md +27 -0
  67. package/pets/clawa/pet.json +7 -0
  68. package/pets/clawa/spritesheet.webp +0 -0
  69. package/pets/clawa/validation.json +808 -0
  70. package/src/desktop/attention.ts +87 -0
  71. package/src/desktop/bridge.ts +31 -0
  72. package/src/desktop/config.ts +145 -0
  73. package/src/desktop/cursor-provider.ts +150 -0
  74. package/src/desktop/main.ts +459 -0
  75. package/src/desktop/preload.ts +19 -0
  76. package/src/pet-image-size.ts +72 -0
  77. package/src/pet-loader.ts +278 -0
  78. package/src/pet-storage.ts +240 -0
  79. package/src/protocol/index.ts +81 -0
@@ -0,0 +1,1207 @@
1
+ #!/usr/bin/env python3
2
+ """Create a Pi Pet run folder, prompts, and visual-generation job manifest."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for Pi Pet and capability-neutral generation.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import math
11
+ import re
12
+ import shutil
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from PIL import Image, ImageDraw
17
+
18
+ ATLAS = {"columns": 8, "rows": 11, "cell_width": 192, "cell_height": 208}
19
+ ATLAS["width"] = ATLAS["columns"] * ATLAS["cell_width"]
20
+ ATLAS["height"] = ATLAS["rows"] * ATLAS["cell_height"]
21
+
22
+ ROWS = [
23
+ ("idle", 0, 6, "calm resting, breathing, and blinking loop"),
24
+ ("running-right", 1, 8, "rightward drag movement loop"),
25
+ ("running-left", 2, 8, "leftward drag movement loop"),
26
+ ("waving", 3, 4, "greeting or attention gesture"),
27
+ ("jumping", 4, 5, "hover or playful jump"),
28
+ ("failed", 5, 8, "blocked, failed, or cancelled reaction"),
29
+ ("waiting", 6, 6, "waiting for approval, help, or user input"),
30
+ ("running", 7, 6, "active task work or processing"),
31
+ ("review", 8, 6, "ready or completed output review"),
32
+ ]
33
+
34
+ LOOK_ROWS = [
35
+ (
36
+ "look-row-9",
37
+ 9,
38
+ ["000", "022.5", "045", "067.5", "090", "112.5", "135", "157.5"],
39
+ "clockwise look directions from up through down-right",
40
+ ),
41
+ (
42
+ "look-row-10",
43
+ 10,
44
+ ["180", "202.5", "225", "247.5", "270", "292.5", "315", "337.5"],
45
+ "clockwise look directions from down through up-left",
46
+ ),
47
+ ]
48
+
49
+ LOOK_CARDINALS = [
50
+ ("000", "up"),
51
+ ("090", "right"),
52
+ ("180", "down"),
53
+ ("270", "left"),
54
+ ]
55
+
56
+ STATE_PROMPTS = {
57
+ "idle": "Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.",
58
+ "running-right": "Dragging-right loop: show directional movement to the right through body and limb poses only.",
59
+ "running-left": "Dragging-left loop: show directional movement to the left through body and limb poses only.",
60
+ "waving": "Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.",
61
+ "jumping": "Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.",
62
+ "failed": "Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.",
63
+ "waiting": "Needs-input loop: expectant asking pose for approval, help, or user input.",
64
+ "running": "Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.",
65
+ "review": "Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.",
66
+ }
67
+
68
+ STATE_REQUIREMENTS = {
69
+ "idle": [
70
+ "CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.",
71
+ "Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.",
72
+ "Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.",
73
+ "Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.",
74
+ "Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.",
75
+ "Feet, base, body, or object anchor should remain planted or nearly planted.",
76
+ "The first and last frames should be very close visually so the loop feels calm and does not pop.",
77
+ ],
78
+ "waving": [
79
+ "Show the greeting through paw, hand, wing, or limb pose only.",
80
+ "Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.",
81
+ ],
82
+ "jumping": [
83
+ "Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.",
84
+ "Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.",
85
+ "Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.",
86
+ ],
87
+ "failed": [
88
+ "Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.",
89
+ "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.",
90
+ "Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.",
91
+ ],
92
+ "waiting": [
93
+ "Show that Pi needs approval, help, or user input through an expectant asking pose.",
94
+ "Keep the motion patient and readable, without turning it into ordinary idle or review.",
95
+ ],
96
+ "running": [
97
+ "Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.",
98
+ "Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
99
+ ],
100
+ "review": [
101
+ "Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.",
102
+ "Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.",
103
+ ],
104
+ "running-right": [
105
+ "Show directional drag movement to the right through body, limb, and prop movement only.",
106
+ "The row must unmistakably face and travel right.",
107
+ "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.",
108
+ "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
109
+ ],
110
+ "running-left": [
111
+ "Show directional drag movement to the left through body, limb, and prop movement only.",
112
+ "The row must unmistakably face and travel left.",
113
+ "The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.",
114
+ "Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.",
115
+ ],
116
+ }
117
+
118
+ NON_DERIVABLE_STATES = {
119
+ "waving",
120
+ "jumping",
121
+ "failed",
122
+ "waiting",
123
+ "running",
124
+ "review",
125
+ }
126
+
127
+ PET_SAFE_STYLE = (
128
+ "Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, "
129
+ "clear silhouette, simple face, stable palette/materials, and crisp edges "
130
+ "for chroma-key extraction."
131
+ )
132
+
133
+ STYLE_PRESETS = {
134
+ "auto": (
135
+ "Infer the most appropriate pet-safe style from the user request and "
136
+ "reference images, then keep that exact style consistent across every row."
137
+ ),
138
+ "pixel": (
139
+ "Pixel-art-adjacent digital mascot with a chunky silhouette, simple dark "
140
+ "outline, limited palette, flat cel shading, and visible stepped edges."
141
+ ),
142
+ "plush": (
143
+ "Soft plush toy mascot with rounded stitched forms, fuzzy fabric feel, "
144
+ "simple sewn details, and readable toy-like proportions."
145
+ ),
146
+ "clay": (
147
+ "Handmade clay or polymer-clay mascot with rounded sculpted forms, soft "
148
+ "material texture, simple features, and clean readable edges."
149
+ ),
150
+ "sticker": (
151
+ "Polished sticker mascot with bold clean shapes, crisp outline, flat "
152
+ "colors, and minimal highlight detail."
153
+ ),
154
+ "flat-vector": (
155
+ "Flat vector-style mascot with simple geometric forms, crisp color areas, "
156
+ "clean outline, and minimal shading."
157
+ ),
158
+ "3d-toy": (
159
+ "Stylized 3D toy mascot with smooth rounded forms, simple materials, "
160
+ "clear silhouette, and no photoreal complexity."
161
+ ),
162
+ "painterly": (
163
+ "Painterly mascot with simplified brush texture, readable forms, stable "
164
+ "palette, and enough edge clarity for clean extraction."
165
+ ),
166
+ "brand-inspired": (
167
+ "Brand-inspired mascot using approved public or user-provided brand cues "
168
+ "such as colors, mascot themes, and vibe while avoiding readable text or "
169
+ "logo copying unless explicitly approved."
170
+ ),
171
+ }
172
+
173
+ CHROMA_KEY_CANDIDATES = [
174
+ ("magenta", "#FF00FF"),
175
+ ("cyan", "#00FFFF"),
176
+ ("yellow", "#FFFF00"),
177
+ ("blue", "#0000FF"),
178
+ ("orange", "#FF7F00"),
179
+ ("green", "#00FF00"),
180
+ ]
181
+
182
+ DEFAULT_PET_NAME = "Sprout"
183
+ CANONICAL_BASE_PATH = "references/canonical-base.png"
184
+ BRAND_DISCOVERY_PATH = "references/brand-discovery.md"
185
+ LAYOUT_GUIDE_DIR = "references/layout-guides"
186
+ LAYOUT_GUIDE_SAFE_MARGIN_X = 18
187
+ LAYOUT_GUIDE_SAFE_MARGIN_Y = 16
188
+ MAX_REFERENCE_COUNT = 16
189
+ MAX_REFERENCE_BYTES = 16 * 1024 * 1024
190
+ MAX_REFERENCE_PIXELS = 16_000_000
191
+ MAX_BRAND_DISCOVERY_BYTES = 256 * 1024
192
+
193
+
194
+ def slugify(value: str) -> str:
195
+ value = value.strip().lower()
196
+ value = re.sub(r"[^a-z0-9]+", "-", value)
197
+ value = re.sub(r"-{2,}", "-", value)
198
+ return value.strip("-")
199
+
200
+
201
+ def display_from_slug(value: str) -> str:
202
+ words = [word for word in re.split(r"[^a-zA-Z0-9]+", value.strip()) if word]
203
+ return " ".join(word.capitalize() for word in words)
204
+
205
+
206
+ def concept_words(value: str) -> list[str]:
207
+ stop_words = {
208
+ "a",
209
+ "an",
210
+ "and",
211
+ "app",
212
+ "based",
213
+ "codex",
214
+ "compact",
215
+ "digital",
216
+ "for",
217
+ "from",
218
+ "in",
219
+ "of",
220
+ "on",
221
+ "pet",
222
+ "pi",
223
+ "ready",
224
+ "small",
225
+ "the",
226
+ "to",
227
+ "with",
228
+ }
229
+ words = [
230
+ word.lower()
231
+ for word in re.findall(r"[a-zA-Z0-9]+", value)
232
+ if word.lower() not in stop_words
233
+ ]
234
+ return words
235
+
236
+
237
+ def infer_name(args: argparse.Namespace, reference_paths: list[Path]) -> str:
238
+ for raw_value in [args.display_name, args.pet_name]:
239
+ value = raw_value.strip()
240
+ if value:
241
+ return value
242
+
243
+ if args.pet_id.strip():
244
+ display = display_from_slug(args.pet_id)
245
+ if display:
246
+ return display
247
+
248
+ for raw_value in [args.pet_notes, args.description, args.brand_name]:
249
+ words = concept_words(raw_value)
250
+ if words:
251
+ return words[0].capitalize()
252
+
253
+ for path in reference_paths:
254
+ display = display_from_slug(path.stem)
255
+ if display:
256
+ return display
257
+
258
+ return DEFAULT_PET_NAME
259
+
260
+
261
+ def sentence(value: str) -> str:
262
+ value = " ".join(value.strip().split())
263
+ if not value:
264
+ return value
265
+ if value[-1] not in ".!?":
266
+ value += "."
267
+ return value
268
+
269
+
270
+ def infer_description(args: argparse.Namespace, reference_paths: list[Path]) -> str:
271
+ if args.description.strip():
272
+ return sentence(args.description)
273
+ if args.pet_notes.strip():
274
+ return sentence(f"A compact Pi Pet: {args.pet_notes}")
275
+ if args.brand_name.strip():
276
+ return sentence(f"A compact Pi Pet inspired by {args.brand_name}")
277
+ if reference_paths:
278
+ return "A compact Pi Pet based on the provided reference image."
279
+ return "A compact original Pi Pet ready for animation."
280
+
281
+
282
+ def infer_pet_notes(args: argparse.Namespace, reference_paths: list[Path]) -> str:
283
+ if args.pet_notes.strip():
284
+ return args.pet_notes.strip()
285
+ if args.description.strip():
286
+ return args.description.strip().rstrip(".")
287
+ if args.brand_name.strip():
288
+ return f"a compact mascot inspired by {args.brand_name.strip()}"
289
+ if reference_paths:
290
+ return "the pet shown in the reference image(s)"
291
+ return "a compact original Pi Pet"
292
+
293
+
294
+ def default_output_dir(pet_id: str) -> Path:
295
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
296
+ return Path.cwd() / "output" / "hatch-pet" / f"{pet_id}-{timestamp}"
297
+
298
+
299
+ def rel(path: Path, root: Path) -> str:
300
+ return str(path.resolve().relative_to(root.resolve()))
301
+
302
+
303
+ def image_metadata(path: Path) -> dict[str, object]:
304
+ with Image.open(path) as image:
305
+ return {
306
+ "path": str(path),
307
+ "width": image.width,
308
+ "height": image.height,
309
+ "mode": image.mode,
310
+ "format": image.format,
311
+ }
312
+
313
+
314
+ def validate_reference(path: Path) -> None:
315
+ size = path.stat().st_size
316
+ if size <= 0 or size > MAX_REFERENCE_BYTES:
317
+ raise SystemExit(f"reference must be 1-{MAX_REFERENCE_BYTES} bytes: {path}")
318
+ try:
319
+ with Image.open(path) as image:
320
+ if image.width <= 0 or image.height <= 0 or image.width * image.height > MAX_REFERENCE_PIXELS:
321
+ raise SystemExit(f"reference exceeds {MAX_REFERENCE_PIXELS} decoded pixels: {path}")
322
+ image.verify()
323
+ except SystemExit:
324
+ raise
325
+ except Exception as error:
326
+ raise SystemExit(f"reference is not a supported image: {path}: {error}") from error
327
+
328
+
329
+ def draw_dashed_line(
330
+ draw: ImageDraw.ImageDraw,
331
+ start: tuple[int, int],
332
+ end: tuple[int, int],
333
+ *,
334
+ fill: str,
335
+ dash: int = 8,
336
+ gap: int = 6,
337
+ ) -> None:
338
+ x1, y1 = start
339
+ x2, y2 = end
340
+ if x1 == x2:
341
+ step = dash + gap
342
+ for y in range(min(y1, y2), max(y1, y2), step):
343
+ draw.line((x1, y, x2, min(y + dash, max(y1, y2))), fill=fill)
344
+ return
345
+ if y1 == y2:
346
+ step = dash + gap
347
+ for x in range(min(x1, x2), max(x1, x2), step):
348
+ draw.line((x, y1, min(x + dash, max(x1, x2)), y2), fill=fill)
349
+ return
350
+ raise ValueError("draw_dashed_line only supports horizontal or vertical lines")
351
+
352
+
353
+ def create_layout_guide(path: Path, state: str, frames: int) -> dict[str, object]:
354
+ width = frames * ATLAS["cell_width"]
355
+ height = ATLAS["cell_height"]
356
+ cell_width = ATLAS["cell_width"]
357
+ image = Image.new("RGB", (width, height), "#f7f7f7")
358
+ draw = ImageDraw.Draw(image)
359
+
360
+ for index in range(frames):
361
+ left = index * cell_width
362
+ right = left + cell_width - 1
363
+ draw.rectangle((left, 0, right, height - 1), outline="#111111", width=2)
364
+
365
+ safe_left = left + LAYOUT_GUIDE_SAFE_MARGIN_X
366
+ safe_top = LAYOUT_GUIDE_SAFE_MARGIN_Y
367
+ safe_right = right - LAYOUT_GUIDE_SAFE_MARGIN_X
368
+ safe_bottom = height - 1 - LAYOUT_GUIDE_SAFE_MARGIN_Y
369
+ draw.rectangle(
370
+ (safe_left, safe_top, safe_right, safe_bottom),
371
+ outline="#2f80ed",
372
+ width=2,
373
+ )
374
+
375
+ center_x = left + cell_width // 2
376
+ center_y = height // 2
377
+ draw_dashed_line(
378
+ draw,
379
+ (center_x, safe_top),
380
+ (center_x, safe_bottom),
381
+ fill="#b8b8b8",
382
+ )
383
+ draw_dashed_line(
384
+ draw,
385
+ (safe_left, center_y),
386
+ (safe_right, center_y),
387
+ fill="#b8b8b8",
388
+ )
389
+
390
+ path.parent.mkdir(parents=True, exist_ok=True)
391
+ image.save(path)
392
+ return {
393
+ "state": state,
394
+ "path": str(path),
395
+ "width": width,
396
+ "height": height,
397
+ "frames": frames,
398
+ "cell_width": ATLAS["cell_width"],
399
+ "cell_height": ATLAS["cell_height"],
400
+ "safe_margin_x": LAYOUT_GUIDE_SAFE_MARGIN_X,
401
+ "safe_margin_y": LAYOUT_GUIDE_SAFE_MARGIN_Y,
402
+ "usage": "layout guide input only; do not copy visible guide lines into generated sprite strips",
403
+ }
404
+
405
+
406
+ def create_layout_guides(run_dir: Path) -> list[dict[str, object]]:
407
+ guide_dir = run_dir / LAYOUT_GUIDE_DIR
408
+ standard_guides = [
409
+ create_layout_guide(guide_dir / f"{state}.png", state, frames)
410
+ for state, _row, frames, _purpose in ROWS
411
+ ]
412
+ look_guides = [
413
+ create_layout_guide(guide_dir / f"{state}.png", state, len(directions))
414
+ for state, _row, directions, _purpose in LOOK_ROWS
415
+ ]
416
+ cardinal_guide = create_layout_guide(
417
+ guide_dir / "look-cardinals.png", "look-cardinals", len(LOOK_CARDINALS)
418
+ )
419
+ return [*standard_guides, *look_guides, cardinal_guide]
420
+
421
+
422
+ def parse_hex_color(value: str) -> tuple[int, int, int]:
423
+ if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
424
+ raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
425
+ return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
426
+
427
+
428
+ def rgb_to_hex(rgb: tuple[int, int, int]) -> str:
429
+ return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
430
+
431
+
432
+ def color_distance(left: tuple[int, int, int], right: tuple[int, int, int]) -> float:
433
+ return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3)))
434
+
435
+
436
+ def sampled_reference_pixels(paths: list[Path]) -> list[tuple[int, int, int]]:
437
+ pixels: list[tuple[int, int, int]] = []
438
+ for path in paths:
439
+ with Image.open(path) as opened:
440
+ image = opened.convert("RGBA")
441
+ image.thumbnail((128, 128), Image.Resampling.LANCZOS)
442
+ data = image.tobytes()
443
+ for index in range(0, len(data), 4):
444
+ red, green, blue, alpha = data[index : index + 4]
445
+ if alpha <= 16:
446
+ continue
447
+ pixels.append((red, green, blue))
448
+
449
+ non_background = [
450
+ pixel for pixel in pixels if not (pixel[0] > 244 and pixel[1] > 244 and pixel[2] > 244)
451
+ ]
452
+ return non_background or pixels
453
+
454
+
455
+ def choose_chroma_key(reference_paths: list[Path], requested: str) -> dict[str, object]:
456
+ if requested.lower() != "auto":
457
+ rgb = parse_hex_color(requested)
458
+ return {
459
+ "hex": rgb_to_hex(rgb),
460
+ "rgb": list(rgb),
461
+ "name": "user-selected",
462
+ "selection": "manual",
463
+ }
464
+
465
+ pixels = sampled_reference_pixels(reference_paths)
466
+ if not pixels:
467
+ rgb = parse_hex_color("#FF00FF")
468
+ return {
469
+ "hex": "#FF00FF",
470
+ "rgb": list(rgb),
471
+ "name": "magenta",
472
+ "selection": "fallback",
473
+ }
474
+
475
+ scored: list[tuple[float, int, str, tuple[int, int, int]]] = []
476
+ for preference_index, (name, hex_color) in enumerate(CHROMA_KEY_CANDIDATES):
477
+ rgb = parse_hex_color(hex_color)
478
+ distances = sorted(color_distance(rgb, pixel) for pixel in pixels)
479
+ percentile_index = max(0, min(len(distances) - 1, int(len(distances) * 0.01)))
480
+ scored.append((distances[percentile_index], -preference_index, name, rgb))
481
+
482
+ score, _preference, name, rgb = max(scored)
483
+ return {
484
+ "hex": rgb_to_hex(rgb),
485
+ "rgb": list(rgb),
486
+ "name": name,
487
+ "selection": "auto",
488
+ "score": round(score, 2),
489
+ }
490
+
491
+
492
+ def write_text(path: Path, text: str) -> None:
493
+ path.parent.mkdir(parents=True, exist_ok=True)
494
+ path.write_text(text.rstrip() + "\n", encoding="utf-8")
495
+
496
+
497
+ def resolved_style_contract(style_preset: str, raw_style_notes: str) -> str:
498
+ style_preset = style_preset.strip().lower()
499
+ if style_preset not in STYLE_PRESETS:
500
+ allowed = ", ".join(sorted(STYLE_PRESETS))
501
+ raise SystemExit(f"invalid style preset: {style_preset}; expected one of: {allowed}")
502
+ raw_style_notes = raw_style_notes.strip()
503
+ preset_contract = STYLE_PRESETS[style_preset]
504
+ if not raw_style_notes:
505
+ return f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract}"
506
+ return (
507
+ f"{PET_SAFE_STYLE} Style `{style_preset}`: {preset_contract} "
508
+ f"User style notes: {raw_style_notes}."
509
+ )
510
+
511
+
512
+ def compact(value: str) -> str:
513
+ return " ".join(value.strip().split())
514
+
515
+
516
+ def brand_inspiration_line(args: argparse.Namespace) -> str:
517
+ brand_name = compact(args.brand_name)
518
+ brand_brief = compact(args.brand_brief)
519
+ if not brand_name and not brand_brief:
520
+ return ""
521
+
522
+ prefix = f"{brand_name}: " if brand_name else ""
523
+ if brand_brief:
524
+ return (
525
+ f"{prefix}{brand_brief} Use only broad mascot-safe cues; do not copy "
526
+ "readable logos, marks, UI screenshots, or text."
527
+ )
528
+ return (
529
+ f"{prefix}Use only broad mascot-safe brand cues. Do not copy readable "
530
+ "logos, marks, UI screenshots, or text."
531
+ )
532
+
533
+
534
+ def base_pet_prompt(args: argparse.Namespace) -> str:
535
+ pet_notes = args.pet_notes or "the pet shown in the reference image(s)"
536
+ style_contract = resolved_style_contract(args.style_preset, args.style_notes)
537
+ brand_line = brand_inspiration_line(args)
538
+ brand_block = f"\nBrand inspiration: {brand_line}\n" if brand_line else "\n"
539
+ chroma_key = args.chroma_key["hex"]
540
+ chroma_name = args.chroma_key["name"]
541
+ return f"""Create one clean full-body reference sprite for Pi Pet {args.display_name}.
542
+
543
+ Pet identity: {pet_notes}.
544
+ Style: {style_contract}
545
+ {brand_block}
546
+ Place a single centered pose on a perfectly flat pure {chroma_name} {chroma_key} chroma-key background. Keep the full pet visible, compact, readable at 192x208, and easy to animate. Preserve approved reference identity cues. No scenery, text, borders, checkerboard transparency, shadows, glows, detached effects, or extra props. Keep {chroma_key} and close colors out of the pet, props, highlights, and effects."""
547
+
548
+
549
+ def row_prompt(args: argparse.Namespace, state: str, row: int, frames: int, purpose: str) -> str:
550
+ pet_notes = args.pet_notes or "the same pet from the approved base reference"
551
+ style_contract = resolved_style_contract(args.style_preset, args.style_notes)
552
+ chroma_key = args.chroma_key["hex"]
553
+ chroma_name = args.chroma_key["name"]
554
+ state_prompt = STATE_PROMPTS[state]
555
+ state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state])
556
+ return f"""Create one horizontal animation strip for Pi Pet `{args.pet_id}`, state `{state}`.
557
+
558
+ Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
559
+
560
+ Output exactly {frames} full-body frames in one left-to-right row on flat pure {chroma_name} {chroma_key}. Treat the row as {frames} invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
561
+
562
+ Identity: same pet in every frame: {pet_notes}. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
563
+ Style: {style_contract}
564
+ Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
565
+
566
+ State action: {state_prompt}
567
+
568
+ State requirements:
569
+ {state_requirements}
570
+
571
+ Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet."""
572
+
573
+
574
+ def retry_row_prompt(
575
+ args: argparse.Namespace, state: str, row: int, frames: int, purpose: str
576
+ ) -> str:
577
+ pet_notes = args.pet_notes or "the canonical base pet"
578
+ chroma_key = args.chroma_key["hex"]
579
+ chroma_name = args.chroma_key["name"]
580
+ state_prompt = STATE_PROMPTS[state]
581
+ state_requirements = "\n".join(f"- {line}" for line in STATE_REQUIREMENTS[state])
582
+ return f"""Create Pi Pet row `{state}` for `{args.pet_id}`: exactly {frames} full-body frames in one horizontal strip on flat pure {chroma_name} {chroma_key}.
583
+
584
+ Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: {pet_notes}. Preserve silhouette, face, palette, material, proportions, markings, and props.
585
+
586
+ Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
587
+
588
+ Action: {state_prompt}
589
+
590
+ State requirements:
591
+ {state_requirements}
592
+
593
+ One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or {chroma_key} colors in the pet."""
594
+
595
+
596
+ def look_row_boundary_contract(row: int) -> str:
597
+ if row == 9:
598
+ return (
599
+ "ROW-BOUNDARY LOCK: 157.5 must be one even 22.5-degree step before 180. "
600
+ "Match the approved 180 pose's body size, baseline, planted anchor, "
601
+ "expression, and construction. Preserve the overall right-hand arc, but "
602
+ "do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component."
603
+ )
604
+ return (
605
+ "ROW-BOUNDARY LOCK: 180 must continue directly from row 9's 157.5, matching "
606
+ "its body size, baseline, planted anchor, expression, and construction. "
607
+ "337.5 must be one even 22.5-degree step before 000: nearly up-facing while "
608
+ "remaining on the overall left-hand arc. Do not distort pupils, nose, or body "
609
+ "geometry merely to exaggerate the subtle horizontal component."
610
+ )
611
+
612
+
613
+ def look_row_layout_contract() -> str:
614
+ return """HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline.
615
+
616
+ Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently.
617
+
618
+ Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite."""
619
+
620
+
621
+ def look_row_screen_coordinate_contract(row: int) -> str:
622
+ if row == 9:
623
+ return (
624
+ "SCREEN-COORDINATE LOCK: screen-right means the viewer's right image edge, "
625
+ "never the character's own right. The row should travel naturally through "
626
+ "the right half of the loop. Near-vertical 022.5 and 157.5 may have subtle "
627
+ "horizontal cues; prioritize a coherent arc over exact pupil or nose placement."
628
+ )
629
+ return (
630
+ "SCREEN-COORDINATE LOCK: screen-left means the viewer's left image edge, never "
631
+ "the character's own left. The row should travel naturally through the left half "
632
+ "of the loop. Near-vertical 202.5 and 337.5 may have subtle horizontal cues; "
633
+ "prioritize a coherent arc over exact pupil or nose placement."
634
+ )
635
+
636
+
637
+ def look_row_axis_contract(row: int) -> str:
638
+ if row == 9:
639
+ slots = [
640
+ "1. `000`: vertical UP; no horizontal requirement.",
641
+ "2. `022.5`: horizontal SCREEN-RIGHT and vertical UP.",
642
+ "3. `045`: horizontal SCREEN-RIGHT and vertical UP.",
643
+ "4. `067.5`: horizontal SCREEN-RIGHT and vertical UP.",
644
+ "5. `090`: horizontal SCREEN-RIGHT; no vertical requirement.",
645
+ "6. `112.5`: horizontal SCREEN-RIGHT and vertical DOWN.",
646
+ "7. `135`: horizontal SCREEN-RIGHT and vertical DOWN.",
647
+ "8. `157.5`: horizontal SCREEN-RIGHT and vertical DOWN.",
648
+ ]
649
+ else:
650
+ slots = [
651
+ "1. `180`: vertical DOWN; no horizontal requirement.",
652
+ "2. `202.5`: horizontal SCREEN-LEFT and vertical DOWN.",
653
+ "3. `225`: horizontal SCREEN-LEFT and vertical DOWN.",
654
+ "4. `247.5`: horizontal SCREEN-LEFT and vertical DOWN.",
655
+ "5. `270`: horizontal SCREEN-LEFT; no vertical requirement.",
656
+ "6. `292.5`: horizontal SCREEN-LEFT and vertical UP.",
657
+ "7. `315`: horizontal SCREEN-LEFT and vertical UP.",
658
+ "8. `337.5`: horizontal SCREEN-LEFT and vertical UP.",
659
+ ]
660
+ return """DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates:
661
+
662
+ {slots}
663
+
664
+ Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.""".format(
665
+ slots="\n".join(slots)
666
+ )
667
+
668
+
669
+ def look_row_pre_return_check(row: int) -> str:
670
+ boundary_check = (
671
+ "157.5 does not flow evenly into 180"
672
+ if row == 9
673
+ else "180 does not continue from 157.5 or 337.5 does not flow evenly into 000"
674
+ )
675
+ return f"""PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or {boundary_check}. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation."""
676
+
677
+
678
+ def look_row_prompt(
679
+ args: argparse.Namespace,
680
+ row: int,
681
+ directions: list[str],
682
+ ) -> str:
683
+ direction_list = ", ".join(directions)
684
+ chroma_key = args.chroma_key["hex"]
685
+ chroma_name = args.chroma_key["name"]
686
+ reference_instruction = (
687
+ "The approved cardinal strip is authoritative for the up, screen-right, down, "
688
+ "and screen-left pose families. Interpolate the intermediate directions as "
689
+ "even 22.5-degree steps between those anchors."
690
+ if row == 9
691
+ else "The approved cardinal strip and completed coherent row 9 are authoritative. "
692
+ "Use the cardinals for direction meaning and row 9 for cross-row identity, scale, "
693
+ "registration, and continuity."
694
+ )
695
+ return f"""Create one horizontal look-direction strip for Pi Pet `{args.pet_id}`, atlas row {row}.
696
+
697
+ Use the attached canonical base, completed standard contact sheet, layout guide, and approved four-cardinal strip for identity, scale, registration, spacing, direction semantics, and cross-row continuity. Read `qa/look-mechanics.md` and follow its pet-specific movement and eye/prop mechanics. {reference_instruction}
698
+
699
+ COHERENT SYNTHESIS LOCK: produce one unified eight-pose row. Do not paste, tile, or independently restyle individual cells. Every final cell must be drawn together with the same face construction, body proportions, line/render quality, lighting, materials, scale, baseline, and registration.
700
+
701
+ Output exactly 8 complete full-body frames in this exact left-to-right order: {direction_list}. Degrees are clockwise: 000 is up, 090 right, 180 down, and 270 left. Neutral/front is not part of this row.
702
+
703
+ {look_row_axis_contract(row)}
704
+
705
+ {look_row_screen_coordinate_contract(row)}
706
+
707
+ {look_row_layout_contract()}
708
+
709
+ Place one centered pose in each invisible equal-width slot on flat pure {chroma_name} {chroma_key}. Change only the natural parts needed to express gaze: eyes, eyelids, head, face, neck, upper body, appendages, and constrained prop follow-through. Keep identity, silhouette, materials, palette, markings, and props consistent.
710
+
711
+ {look_row_boundary_contract(row)}
712
+
713
+ {look_row_pre_return_check(row)}
714
+
715
+ Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement/googly eyes, labels, degree text, arrows, clocks, grids, shadows, glows, scenery, detached effects, or chroma-key colors inside the pet."""
716
+
717
+
718
+ def retry_look_row_prompt(
719
+ args: argparse.Namespace,
720
+ row: int,
721
+ directions: list[str],
722
+ ) -> str:
723
+ direction_list = ", ".join(directions)
724
+ chroma_key = args.chroma_key["hex"]
725
+ chroma_name = args.chroma_key["name"]
726
+ return f"""Create Pi Pet v2 look row {row} for `{args.pet_id}` as exactly 8 full-body frames in this order: {direction_list}.
727
+
728
+ Use the canonical base, standard contact sheet, layout guide, approved four-cardinal strip, and `qa/look-mechanics.md`. Draw the complete eight-pose row as one coherent animation family, interpolating even 22.5-degree steps between the cardinal pose families. Keep the same pet identity, face construction, materials, palette, markings, and props. Each direction must read correctly at pet size and join continuously at the 000 and 180 boundaries.
729
+
730
+ {look_row_axis_contract(row)}
731
+
732
+ {look_row_layout_contract()}
733
+
734
+ {look_row_boundary_contract(row)}
735
+
736
+ {look_row_pre_return_check(row)}
737
+
738
+ Use a flat pure {chroma_name} {chroma_key} background. One complete unclipped pose per invisible slot. No whole-sprite rotation, replacement eyes, labels, guide marks, shadows, glows, scenery, detached effects, or {chroma_key} colors in the pet."""
739
+
740
+
741
+ def look_cardinal_prompt(args: argparse.Namespace) -> str:
742
+ chroma_key = args.chroma_key["hex"]
743
+ chroma_name = args.chroma_key["name"]
744
+ return f"""Create one horizontal four-cardinal anchor strip for Pi Pet `{args.pet_id}`.
745
+
746
+ Use the attached canonical base, completed standard contact sheet, and layout guide for exact identity, style, scale, baseline, face construction, materials, palette, markings, props, and spacing. Read `qa/look-mechanics.md` and use the pet's natural gaze mechanism.
747
+
748
+ Output exactly four centered complete full-body poses in this exact left-to-right order: `000 up`, `090 screen-right`, `180 down`, `270 screen-left`. Screen-left and screen-right always mean the viewer's image edges, never the character's own left or right.
749
+
750
+ For `000`, keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge. For `090`, put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center. For `180`, keep the face broadly frontal and point toward the BOTTOM edge. For `270`, apply the inverse screen-left landmark rule. Every cardinal must be unmistakable without labels.
751
+
752
+ Place one pose in each invisible equal-width slot on a flat pure {chroma_name} {chroma_key} background with generous padding. Keep scale, feet/base, lower body, and registration consistent across all four slots.
753
+
754
+ Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, degree text, arrows, boxes, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet."""
755
+
756
+
757
+ def look_cardinal_repair_prompt(
758
+ args: argparse.Namespace,
759
+ label: str,
760
+ expected_direction: str,
761
+ ) -> str:
762
+ chroma_key = args.chroma_key["hex"]
763
+ chroma_name = args.chroma_key["name"]
764
+ screen_rule = {
765
+ "000": "Keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge.",
766
+ "090": "Put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center.",
767
+ "180": "Keep the face broadly frontal and point the eyes and natural head mechanism toward the BOTTOM edge.",
768
+ "270": "Put the nose tip, pupils, face surface, or natural aiming feature on the screen-left side of the head center.",
769
+ }[label]
770
+ return f"""Repair one cardinal anchor for Pi Pet `{args.pet_id}`: `{label}` means looking {expected_direction}.
771
+
772
+ Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. {screen_rule} Screen coordinates are viewer-relative.
773
+
774
+ Output one centered complete full-body pose on a flat pure {chroma_name} {chroma_key} background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size.
775
+
776
+ Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet."""
777
+
778
+
779
+ def make_jobs(
780
+ run_dir: Path,
781
+ copied_refs: list[dict[str, object]],
782
+ ) -> list[dict[str, object]]:
783
+ reference_inputs = [
784
+ {"path": rel(Path(str(ref["copied_path"])), run_dir), "role": "pet reference"}
785
+ for ref in copied_refs
786
+ ]
787
+ identity_reference_paths = [CANONICAL_BASE_PATH]
788
+ jobs: list[dict[str, object]] = [
789
+ {
790
+ "id": "base",
791
+ "kind": "base-pet",
792
+ "status": "pending",
793
+ "prompt_file": "prompts/base-pet.md",
794
+ "input_images": reference_inputs,
795
+ "output_path": "decoded/base.png",
796
+ "depends_on": [],
797
+ "generation_capability": "image-generation",
798
+ "requires_grounded_generation": bool(reference_inputs),
799
+ "allow_prompt_only_generation": not reference_inputs,
800
+ }
801
+ ]
802
+ for state, _row, frames, _purpose in ROWS:
803
+ depends_on = ["base"]
804
+ extra_inputs: list[dict[str, str]] = []
805
+ derivation_policy: dict[str, object] = {
806
+ "may_derive": False,
807
+ "reason": "state requires its own generated animation semantics",
808
+ }
809
+ if state == "running-left":
810
+ depends_on.append("running-right")
811
+ extra_inputs.append(
812
+ {
813
+ "path": "decoded/running-right.png",
814
+ "role": "rightward gait reference for leftward row decision",
815
+ }
816
+ )
817
+ derivation_policy = {
818
+ "may_derive": True,
819
+ "may_derive_from": "running-right",
820
+ "derivation": "framewise-horizontal-mirror-preserving-order",
821
+ "requires_explicit_approval": True,
822
+ "fallback_generation_capability": "image-generation",
823
+ }
824
+ elif state not in NON_DERIVABLE_STATES:
825
+ derivation_policy["reason"] = "no deterministic derivation is configured for this state"
826
+ jobs.append(
827
+ {
828
+ "id": state,
829
+ "kind": "row-strip",
830
+ "status": "pending",
831
+ "prompt_file": f"prompts/rows/{state}.md",
832
+ "retry_prompt_file": f"prompts/row-retries/{state}.md",
833
+ "input_images": [
834
+ *reference_inputs,
835
+ {
836
+ "path": f"{LAYOUT_GUIDE_DIR}/{state}.png",
837
+ "role": f"layout guide for {frames} frame slots; use for spacing only, do not copy guide lines",
838
+ },
839
+ {
840
+ "path": CANONICAL_BASE_PATH,
841
+ "role": "canonical identity reference",
842
+ },
843
+ *extra_inputs,
844
+ ],
845
+ "output_path": f"decoded/{state}.png",
846
+ "depends_on": depends_on,
847
+ "generation_capability": "image-generation",
848
+ "requires_grounded_generation": True,
849
+ "allow_prompt_only_generation": False,
850
+ "identity_reference_paths": identity_reference_paths,
851
+ "parallelizable_after": depends_on,
852
+ "derivation_policy": derivation_policy,
853
+ "mirror_policy": derivation_policy if state == "running-left" else {},
854
+ }
855
+ )
856
+ standard_job_ids = [state for state, _row, _frames, _purpose in ROWS]
857
+ jobs.append(
858
+ {
859
+ "id": "look-cardinals",
860
+ "kind": "look-cardinal-strip",
861
+ "status": "pending",
862
+ "prompt_file": "prompts/look-cardinals.md",
863
+ "repair_prompt_files": {
864
+ label: f"prompts/look-anchor-repairs/{label}.md"
865
+ for label, _direction in LOOK_CARDINALS
866
+ },
867
+ "input_images": [
868
+ *reference_inputs,
869
+ {
870
+ "path": f"{LAYOUT_GUIDE_DIR}/look-cardinals.png",
871
+ "role": "layout guide for four cardinal slots; use for spacing only, do not copy guide lines",
872
+ },
873
+ {
874
+ "path": CANONICAL_BASE_PATH,
875
+ "role": "canonical identity reference",
876
+ },
877
+ {
878
+ "path": "qa/contact-sheet.png",
879
+ "role": "approved standard-row identity, scale, and baseline reference",
880
+ },
881
+ ],
882
+ "output_path": "decoded/look-cardinals.png",
883
+ "extracted_output_paths": [
884
+ f"decoded/look-anchors/{label}.png" for label, _direction in LOOK_CARDINALS
885
+ ],
886
+ "approved_strip_path": "decoded/look-anchors-approved.png",
887
+ "depends_on": standard_job_ids,
888
+ "generation_capability": "image-generation",
889
+ "requires_grounded_generation": True,
890
+ "allow_prompt_only_generation": False,
891
+ "identity_reference_paths": identity_reference_paths,
892
+ "look_mechanics_file": "qa/look-mechanics.md",
893
+ "directions": [label for label, _direction in LOOK_CARDINALS],
894
+ "packaging_eligible": False,
895
+ "parallelizable_after": standard_job_ids,
896
+ "derivation_policy": {
897
+ "may_derive": False,
898
+ "reason": "cardinal directions require grounded pet-specific generation",
899
+ },
900
+ }
901
+ )
902
+ jobs.append(
903
+ {
904
+ "id": "look-cardinals-approved",
905
+ "kind": "visual-approval-gate",
906
+ "status": "pending",
907
+ "depends_on": ["look-cardinals"],
908
+ "required_artifacts": [
909
+ "qa/look-mechanics.md",
910
+ "qa/cardinal-anchors.json",
911
+ "decoded/look-anchors-approved.png",
912
+ ],
913
+ "generation_capability": None,
914
+ "packaging_eligible": False,
915
+ }
916
+ )
917
+ for state, row, directions, _purpose in LOOK_ROWS:
918
+ depends_on = (
919
+ ["look-cardinals-approved"]
920
+ if row == 9
921
+ else ["look-cardinals-approved", "look-row-9"]
922
+ )
923
+ continuity_inputs = (
924
+ []
925
+ if row == 9
926
+ else [
927
+ {
928
+ "path": "decoded/look-row-9.png",
929
+ "role": "completed first half of the clockwise look loop for row 10 continuity",
930
+ }
931
+ ]
932
+ )
933
+ jobs.append(
934
+ {
935
+ "id": state,
936
+ "kind": "look-row-strip",
937
+ "status": "pending",
938
+ "prompt_file": f"prompts/rows/{state}.md",
939
+ "retry_prompt_file": f"prompts/row-retries/{state}.md",
940
+ "input_images": [
941
+ *reference_inputs,
942
+ {
943
+ "path": f"{LAYOUT_GUIDE_DIR}/{state}.png",
944
+ "role": "layout guide for 8 direction slots; use for spacing only, do not copy guide lines",
945
+ },
946
+ {
947
+ "path": CANONICAL_BASE_PATH,
948
+ "role": "canonical identity reference",
949
+ },
950
+ {
951
+ "path": "qa/contact-sheet.png",
952
+ "role": "approved standard-row identity, scale, and baseline reference",
953
+ },
954
+ {
955
+ "path": "decoded/look-anchors-approved.png",
956
+ "role": "approved cardinal reference strip in order 000 up, 090 screen-right, 180 down, 270 screen-left; interpolate intermediate directions evenly",
957
+ },
958
+ *continuity_inputs,
959
+ ],
960
+ "output_path": f"decoded/{state}.png",
961
+ "depends_on": depends_on,
962
+ "generation_capability": "image-generation",
963
+ "requires_grounded_generation": True,
964
+ "allow_prompt_only_generation": False,
965
+ "identity_reference_paths": identity_reference_paths,
966
+ "look_mechanics_file": "qa/look-mechanics.md",
967
+ "directions": directions,
968
+ "parallelizable_after": depends_on,
969
+ "derivation_policy": {
970
+ "may_derive": False,
971
+ "reason": "look directions require grounded pet-specific generation",
972
+ },
973
+ "coherent_synthesis_required": True,
974
+ "individual_cell_packaging_allowed": False,
975
+ "packaging_eligible": True,
976
+ }
977
+ )
978
+ return jobs
979
+
980
+
981
+ def main() -> None:
982
+ parser = argparse.ArgumentParser(description=__doc__)
983
+ parser.add_argument(
984
+ "--pet-name",
985
+ default="",
986
+ help="User-facing pet name. Ask the user for this when practical; otherwise choose a short appropriate name.",
987
+ )
988
+ parser.add_argument(
989
+ "--pet-id",
990
+ default="",
991
+ help="Stable pet folder/id slug. Defaults to the slugified pet name.",
992
+ )
993
+ parser.add_argument(
994
+ "--display-name",
995
+ default="",
996
+ help="Display label. Defaults to the pet name.",
997
+ )
998
+ parser.add_argument("--description", default="")
999
+ parser.add_argument("--reference", action="append", default=[])
1000
+ parser.add_argument("--output-dir", default="")
1001
+ parser.add_argument("--pet-notes", default="")
1002
+ parser.add_argument(
1003
+ "--brand-name",
1004
+ default="",
1005
+ help="Brand, company, or product name used for broad mascot inspiration.",
1006
+ )
1007
+ parser.add_argument(
1008
+ "--brand-brief",
1009
+ default="",
1010
+ help="Compact researched brand cue sentence for the base pet only.",
1011
+ )
1012
+ parser.add_argument(
1013
+ "--brand-source",
1014
+ action="append",
1015
+ default=[],
1016
+ help="Source URL used to produce the brand brief. May be passed multiple times.",
1017
+ )
1018
+ parser.add_argument(
1019
+ "--brand-discovery-file",
1020
+ default="",
1021
+ help="Optional markdown discovery brief to copy into the run for review.",
1022
+ )
1023
+ parser.add_argument(
1024
+ "--style-preset",
1025
+ default="auto",
1026
+ choices=sorted(STYLE_PRESETS),
1027
+ help="Pet-safe style preset to use across the base and all animation rows.",
1028
+ )
1029
+ parser.add_argument("--style-notes", default="")
1030
+ parser.add_argument(
1031
+ "--chroma-key",
1032
+ default="auto",
1033
+ help="Chroma key as #RRGGBB, or auto to choose a safe key from reference colors.",
1034
+ )
1035
+ parser.add_argument("--force", action="store_true")
1036
+ args = parser.parse_args()
1037
+
1038
+ raw_reference_paths = [Path(raw_path).expanduser().resolve() for raw_path in args.reference]
1039
+ if len(raw_reference_paths) > MAX_REFERENCE_COUNT:
1040
+ raise SystemExit(f"at most {MAX_REFERENCE_COUNT} references are allowed")
1041
+ raw_brand_discovery_path = (
1042
+ Path(args.brand_discovery_file).expanduser().resolve()
1043
+ if args.brand_discovery_file.strip()
1044
+ else None
1045
+ )
1046
+ for source in raw_reference_paths:
1047
+ if not source.is_file():
1048
+ raise SystemExit(f"reference not found: {source}")
1049
+ validate_reference(source)
1050
+ if raw_brand_discovery_path is not None:
1051
+ if not raw_brand_discovery_path.is_file():
1052
+ raise SystemExit(f"brand discovery file not found: {raw_brand_discovery_path}")
1053
+ if raw_brand_discovery_path.stat().st_size > MAX_BRAND_DISCOVERY_BYTES:
1054
+ raise SystemExit(f"brand discovery exceeds {MAX_BRAND_DISCOVERY_BYTES} bytes")
1055
+
1056
+ args.display_name = infer_name(args, raw_reference_paths)
1057
+ args.pet_name = (args.pet_name or args.display_name).strip()
1058
+ args.description = infer_description(args, raw_reference_paths)
1059
+ args.pet_notes = infer_pet_notes(args, raw_reference_paths)
1060
+ args.pet_id = slugify(args.pet_id or args.pet_name or args.display_name)
1061
+ args.style_preset = args.style_preset.strip().lower()
1062
+ args.style_contract = resolved_style_contract(args.style_preset, args.style_notes)
1063
+ args.brand_name = compact(args.brand_name)
1064
+ args.brand_brief = compact(args.brand_brief)
1065
+ args.brand_source = [compact(source) for source in args.brand_source if compact(source)]
1066
+ if not args.pet_id:
1067
+ raise SystemExit("pet id must contain at least one letter or digit")
1068
+
1069
+ run_dir = (
1070
+ Path(args.output_dir).expanduser().resolve()
1071
+ if args.output_dir
1072
+ else default_output_dir(args.pet_id).resolve()
1073
+ )
1074
+ if run_dir.exists() and any(run_dir.iterdir()) and not args.force:
1075
+ raise SystemExit(f"{run_dir} already exists and is not empty; pass --force to reuse it")
1076
+ run_dir.mkdir(parents=True, exist_ok=True)
1077
+
1078
+ ref_dir = run_dir / "references"
1079
+ prompt_dir = run_dir / "prompts"
1080
+ row_prompt_dir = prompt_dir / "rows"
1081
+ row_retry_prompt_dir = prompt_dir / "row-retries"
1082
+ look_anchor_repair_prompt_dir = prompt_dir / "look-anchor-repairs"
1083
+ for directory in [
1084
+ ref_dir,
1085
+ prompt_dir,
1086
+ row_prompt_dir,
1087
+ row_retry_prompt_dir,
1088
+ look_anchor_repair_prompt_dir,
1089
+ run_dir / "decoded",
1090
+ run_dir / "qa",
1091
+ ]:
1092
+ directory.mkdir(parents=True, exist_ok=True)
1093
+
1094
+ copied_refs: list[dict[str, object]] = []
1095
+ copied_ref_paths: list[Path] = []
1096
+ for index, source in enumerate(raw_reference_paths, start=1):
1097
+ suffix = source.suffix.lower() or ".png"
1098
+ copied = ref_dir / f"reference-{index:02d}{suffix}"
1099
+ shutil.copy2(source, copied)
1100
+ validate_reference(copied)
1101
+ meta = image_metadata(copied)
1102
+ meta["source_path"] = str(source)
1103
+ meta["copied_path"] = str(copied)
1104
+ copied_refs.append(meta)
1105
+ copied_ref_paths.append(copied)
1106
+
1107
+ brand_discovery_path = ""
1108
+ if raw_brand_discovery_path is not None:
1109
+ copied_discovery = run_dir / BRAND_DISCOVERY_PATH
1110
+ shutil.copy2(raw_brand_discovery_path, copied_discovery)
1111
+ brand_discovery_path = rel(copied_discovery, run_dir)
1112
+
1113
+ args.chroma_key = choose_chroma_key(copied_ref_paths, args.chroma_key)
1114
+ layout_guides = create_layout_guides(run_dir)
1115
+
1116
+ request = {
1117
+ "pet_id": args.pet_id,
1118
+ "display_name": args.display_name,
1119
+ "description": args.description,
1120
+ "created_at": datetime.now(timezone.utc).isoformat(),
1121
+ "sprite_version_number": 2,
1122
+ "atlas": ATLAS,
1123
+ "rows": [
1124
+ {"state": state, "row": row, "frames": frames, "purpose": purpose}
1125
+ for state, row, frames, purpose in ROWS
1126
+ ]
1127
+ + [
1128
+ {
1129
+ "state": state,
1130
+ "row": row,
1131
+ "frames": len(directions),
1132
+ "directions": directions,
1133
+ "purpose": purpose,
1134
+ }
1135
+ for state, row, directions, purpose in LOOK_ROWS
1136
+ ],
1137
+ "layout_guides": [
1138
+ {**guide, "path": rel(Path(str(guide["path"])), run_dir)} for guide in layout_guides
1139
+ ],
1140
+ "references": copied_refs,
1141
+ "chroma_key": args.chroma_key,
1142
+ "pet_notes": args.pet_notes,
1143
+ "style_preset": args.style_preset,
1144
+ "style_notes": args.style_notes,
1145
+ "style_contract": args.style_contract,
1146
+ "brand_name": args.brand_name,
1147
+ "brand_brief": args.brand_brief,
1148
+ "brand_sources": args.brand_source,
1149
+ "pet_safe_style": PET_SAFE_STYLE,
1150
+ "primary_generation_capability": "image-generation",
1151
+ }
1152
+ if brand_discovery_path:
1153
+ request["brand_discovery_path"] = brand_discovery_path
1154
+ (run_dir / "pet_request.json").write_text(
1155
+ json.dumps(request, indent=2) + "\n", encoding="utf-8"
1156
+ )
1157
+
1158
+ write_text(prompt_dir / "base-pet.md", base_pet_prompt(args))
1159
+ for state, row, frames, purpose in ROWS:
1160
+ write_text(
1161
+ row_prompt_dir / f"{state}.md",
1162
+ row_prompt(args, state, row, frames, purpose),
1163
+ )
1164
+ write_text(
1165
+ row_retry_prompt_dir / f"{state}.md",
1166
+ retry_row_prompt(args, state, row, frames, purpose),
1167
+ )
1168
+ for state, row, directions, _purpose in LOOK_ROWS:
1169
+ write_text(
1170
+ row_prompt_dir / f"{state}.md",
1171
+ look_row_prompt(args, row, directions),
1172
+ )
1173
+ write_text(
1174
+ row_retry_prompt_dir / f"{state}.md",
1175
+ retry_look_row_prompt(args, row, directions),
1176
+ )
1177
+ write_text(prompt_dir / "look-cardinals.md", look_cardinal_prompt(args))
1178
+ for label, expected_direction in LOOK_CARDINALS:
1179
+ write_text(
1180
+ look_anchor_repair_prompt_dir / f"{label}.md",
1181
+ look_cardinal_repair_prompt(args, label, expected_direction),
1182
+ )
1183
+ jobs = {
1184
+ "schema_version": 1,
1185
+ "created_at": datetime.now(timezone.utc).isoformat(),
1186
+ "run_dir": str(run_dir),
1187
+ "primary_generation_capability": "image-generation",
1188
+ "jobs": make_jobs(run_dir, copied_refs),
1189
+ }
1190
+ (run_dir / "visual-jobs.json").write_text(json.dumps(jobs, indent=2) + "\n", encoding="utf-8")
1191
+
1192
+ print(
1193
+ json.dumps(
1194
+ {
1195
+ "ok": True,
1196
+ "run_dir": str(run_dir),
1197
+ "request": str(run_dir / "pet_request.json"),
1198
+ "jobs": str(run_dir / "visual-jobs.json"),
1199
+ "ready_jobs": ["base"],
1200
+ },
1201
+ indent=2,
1202
+ )
1203
+ )
1204
+
1205
+
1206
+ if __name__ == "__main__":
1207
+ main()