@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,59 @@
1
+ #!/usr/bin/env python3
2
+ """Combine independent blind direction verdicts by strict per-cell majority."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for the Pi Pet authoring package.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ from collections import Counter
11
+ from pathlib import Path
12
+
13
+
14
+ def load_pairs(path: str) -> dict[str, dict[str, object]]:
15
+ payload = json.loads(Path(path).expanduser().resolve().read_text(encoding="utf-8"))
16
+ return {entry["pair"]: entry for entry in payload.get("pairs", [])}
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("--verdicts", action="append", required=True)
22
+ parser.add_argument("--json-out", required=True)
23
+ args = parser.parse_args()
24
+
25
+ if len(args.verdicts) < 3 or len(args.verdicts) % 2 == 0:
26
+ raise SystemExit("provide an odd number of at least three verdict files")
27
+
28
+ verdict_paths = [Path(path).expanduser().resolve() for path in args.verdicts]
29
+ if len(set(verdict_paths)) != len(verdict_paths):
30
+ raise SystemExit("verdict files must be distinct")
31
+
32
+ reviews = [load_pairs(str(path)) for path in verdict_paths]
33
+ pair_ids = set(reviews[0])
34
+ if any(set(review) != pair_ids for review in reviews[1:]):
35
+ raise SystemExit("all verdict files must contain the same pair ids")
36
+
37
+ threshold = len(reviews) // 2 + 1
38
+ combined = []
39
+ for pair_id in reviews[0]:
40
+ result: dict[str, object] = {"pair": pair_id}
41
+ vote_summary: dict[str, object] = {}
42
+ for slot in ("A", "B"):
43
+ votes = [review[pair_id].get(slot) for review in reviews]
44
+ counts = Counter(votes)
45
+ direction, count = counts.most_common(1)[0]
46
+ result[slot] = direction if count >= threshold else "ambiguous"
47
+ vote_summary[slot] = dict(counts)
48
+ result["reason"] = "strict majority of independent blind reviews"
49
+ result["votes"] = vote_summary
50
+ combined.append(result)
51
+
52
+ output = Path(args.json_out).expanduser().resolve()
53
+ output.parent.mkdir(parents=True, exist_ok=True)
54
+ output.write_text(json.dumps({"pairs": combined}, indent=2) + "\n", encoding="utf-8")
55
+ print(output)
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Compose or normalize a Codex pet spritesheet atlas."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for the Pi Pet authoring package.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ from PIL import Image
12
+
13
+ COLUMNS = 8
14
+ ROWS = 9
15
+ CELL_WIDTH = 192
16
+ CELL_HEIGHT = 208
17
+ ATLAS_WIDTH = COLUMNS * CELL_WIDTH
18
+ ATLAS_HEIGHT = ROWS * CELL_HEIGHT
19
+ ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT
20
+ ROW_SPECS = [
21
+ ("idle", 0, 6),
22
+ ("running-right", 1, 8),
23
+ ("running-left", 2, 8),
24
+ ("waving", 3, 4),
25
+ ("jumping", 4, 5),
26
+ ("failed", 5, 8),
27
+ ("waiting", 6, 6),
28
+ ("running", 7, 6),
29
+ ("review", 8, 6),
30
+ ]
31
+ IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
32
+
33
+
34
+ def image_files(path: Path) -> list[Path]:
35
+ return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
36
+
37
+
38
+ def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]:
39
+ candidates = [
40
+ root / state,
41
+ root / f"row-{row_index}",
42
+ root / f"row{row_index}",
43
+ root / f"{row_index}-{state}",
44
+ ]
45
+ for candidate in candidates:
46
+ if candidate.is_dir():
47
+ files = image_files(candidate)
48
+ if files:
49
+ return files
50
+ globs = [
51
+ f"{state}_*",
52
+ f"{state}-*",
53
+ f"row{row_index}_*",
54
+ f"row-{row_index}-*",
55
+ ]
56
+ files: list[Path] = []
57
+ for pattern in globs:
58
+ files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES)
59
+ return sorted(set(files))
60
+
61
+
62
+ def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None:
63
+ frame = source.convert("RGBA")
64
+ if frame.size != (CELL_WIDTH, CELL_HEIGHT):
65
+ frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
66
+ left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2
67
+ top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2
68
+ atlas.alpha_composite(frame, (left, top))
69
+
70
+
71
+ def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image:
72
+ with Image.open(path) as opened:
73
+ source = opened.convert("RGBA")
74
+ if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
75
+ if not resize_source:
76
+ raise SystemExit(
77
+ f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}"
78
+ )
79
+ source_ratio = source.width / source.height
80
+ if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02:
81
+ raise SystemExit(
82
+ "refusing to resize source atlas because its aspect ratio does not match "
83
+ f"the Codex atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. "
84
+ "Generate exact atlas dimensions or use --frames-root."
85
+ )
86
+ source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS)
87
+
88
+ atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
89
+ for _state, row, frame_count in ROW_SPECS:
90
+ for column in range(frame_count):
91
+ left = column * CELL_WIDTH
92
+ top = row * CELL_HEIGHT
93
+ cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
94
+ atlas.alpha_composite(cell, (left, top))
95
+ return atlas
96
+
97
+
98
+ def compose_from_frames(root: Path) -> Image.Image:
99
+ atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
100
+ for state, row, frame_count in ROW_SPECS:
101
+ files = find_row_frames(root, state, row)
102
+ if len(files) < frame_count:
103
+ raise SystemExit(
104
+ f"{state} row needs {frame_count} frames, found {len(files)} under {root}"
105
+ )
106
+ for column, frame_path in enumerate(files[:frame_count]):
107
+ with Image.open(frame_path) as frame:
108
+ paste_centered(atlas, frame, row, column)
109
+ return atlas
110
+
111
+
112
+ def clear_transparent_rgb(image: Image.Image) -> Image.Image:
113
+ rgba = image.convert("RGBA")
114
+ data = bytearray(rgba.tobytes())
115
+ for index in range(0, len(data), 4):
116
+ if data[index + 3] == 0:
117
+ data[index] = 0
118
+ data[index + 1] = 0
119
+ data[index + 2] = 0
120
+ return Image.frombytes("RGBA", rgba.size, bytes(data))
121
+
122
+
123
+ def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
124
+ atlas = clear_transparent_rgb(atlas)
125
+ output.parent.mkdir(parents=True, exist_ok=True)
126
+ atlas.save(output)
127
+ if webp_output is not None:
128
+ webp_output.parent.mkdir(parents=True, exist_ok=True)
129
+ atlas.save(
130
+ webp_output,
131
+ format="WEBP",
132
+ lossless=True,
133
+ quality=100,
134
+ method=6,
135
+ exact=True,
136
+ )
137
+
138
+
139
+ def main() -> None:
140
+ parser = argparse.ArgumentParser(description=__doc__)
141
+ source = parser.add_mutually_exclusive_group(required=True)
142
+ source.add_argument("--source-atlas")
143
+ source.add_argument("--frames-root")
144
+ parser.add_argument("--output", required=True)
145
+ parser.add_argument("--webp-output")
146
+ parser.add_argument(
147
+ "--resize-source",
148
+ action="store_true",
149
+ help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.",
150
+ )
151
+ args = parser.parse_args()
152
+
153
+ if args.source_atlas:
154
+ atlas = compose_from_source_atlas(
155
+ Path(args.source_atlas).expanduser().resolve(), args.resize_source
156
+ )
157
+ else:
158
+ atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve())
159
+
160
+ save_outputs(
161
+ atlas,
162
+ Path(args.output).expanduser().resolve(),
163
+ Path(args.webp_output).expanduser().resolve() if args.webp_output else None,
164
+ )
165
+ print(f"wrote {Path(args.output).expanduser().resolve()}")
166
+ if args.webp_output:
167
+ print(f"wrote {Path(args.webp_output).expanduser().resolve()}")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Compose approved cardinal reference cells."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for the Pi Pet authoring package.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ from PIL import Image
12
+
13
+ CELL_SIZE = (192, 208)
14
+ CARDINALS = ("000", "090", "180", "270")
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(description=__doc__)
19
+ parser.add_argument("--anchors-dir", required=True)
20
+ parser.add_argument("--output", required=True)
21
+ args = parser.parse_args()
22
+
23
+ anchors_dir = Path(args.anchors_dir).expanduser().resolve()
24
+ output = Path(args.output).expanduser().resolve()
25
+ strip = Image.new("RGBA", (CELL_SIZE[0] * len(CARDINALS), CELL_SIZE[1]))
26
+
27
+ for index, direction in enumerate(CARDINALS):
28
+ path = anchors_dir / f"{direction}.png"
29
+ if not path.is_file():
30
+ raise SystemExit(f"missing approved cardinal reference: {path}")
31
+ with Image.open(path) as opened:
32
+ reference = opened.convert("RGBA")
33
+ if reference.size != CELL_SIZE:
34
+ raise SystemExit(f"{path} is {reference.size}; expected {CELL_SIZE}")
35
+ if reference.getbbox() is None:
36
+ raise SystemExit(f"approved cardinal reference is empty: {path}")
37
+ strip.alpha_composite(reference, (index * CELL_SIZE[0], 0))
38
+
39
+ output.parent.mkdir(parents=True, exist_ok=True)
40
+ strip.save(output)
41
+ print(f"wrote {output}")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Conditionally derive running-left by mirroring the approved running-right strip."""
3
+
4
+ # Modified from the Apache-2.0 Hatch Pet workflow for Pi Pet's visual-jobs manifest.
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from PIL import Image, ImageOps
15
+
16
+ RUNNING_FRAME_COUNT = 8
17
+
18
+
19
+ def load_manifest(run_dir: Path) -> dict[str, object]:
20
+ path = run_dir / "visual-jobs.json"
21
+ if not path.exists():
22
+ raise SystemExit(f"job manifest not found: {path}")
23
+ return json.loads(path.read_text(encoding="utf-8"))
24
+
25
+
26
+ def job_list(manifest: dict[str, object]) -> list[dict[str, object]]:
27
+ jobs = manifest.get("jobs")
28
+ if not isinstance(jobs, list):
29
+ raise SystemExit("invalid visual-jobs.json: jobs must be a list")
30
+ return [job for job in jobs if isinstance(job, dict)]
31
+
32
+
33
+ def find_job(manifest: dict[str, object], job_id: str) -> dict[str, object]:
34
+ for job in job_list(manifest):
35
+ if job.get("id") == job_id:
36
+ return job
37
+ raise SystemExit(f"unknown job id: {job_id}")
38
+
39
+
40
+ def image_metadata(path: Path) -> dict[str, object]:
41
+ with Image.open(path) as image:
42
+ image.verify()
43
+ with Image.open(path) as image:
44
+ return {
45
+ "width": image.width,
46
+ "height": image.height,
47
+ "mode": image.mode,
48
+ "format": image.format,
49
+ }
50
+
51
+
52
+ def manifest_relative(path: Path, run_dir: Path) -> str:
53
+ return str(path.resolve().relative_to(run_dir.resolve()))
54
+
55
+
56
+ def mirror_strip_preserving_frame_order(
57
+ source: Image.Image,
58
+ frame_count: int = RUNNING_FRAME_COUNT,
59
+ ) -> Image.Image:
60
+ rgba = source.convert("RGBA")
61
+ mirrored = Image.new("RGBA", rgba.size, (0, 0, 0, 0))
62
+ slot_width = rgba.width / frame_count
63
+ for index in range(frame_count):
64
+ left = round(index * slot_width)
65
+ right = round((index + 1) * slot_width)
66
+ mirrored.alpha_composite(
67
+ ImageOps.mirror(rgba.crop((left, 0, right, rgba.height))),
68
+ (left, 0),
69
+ )
70
+ return mirrored
71
+
72
+
73
+ def main() -> None:
74
+ parser = argparse.ArgumentParser(description=__doc__)
75
+ parser.add_argument("--run-dir", required=True)
76
+ parser.add_argument(
77
+ "--confirm-appropriate-mirror",
78
+ action="store_true",
79
+ help="Required after visually confirming the rightward strip can be mirrored without identity/prop issues.",
80
+ )
81
+ parser.add_argument(
82
+ "--decision-note",
83
+ required=True,
84
+ help="Short note explaining why mirroring is acceptable for this pet.",
85
+ )
86
+ parser.add_argument("--force", action="store_true")
87
+ args = parser.parse_args()
88
+
89
+ if not args.confirm_appropriate_mirror:
90
+ raise SystemExit("refusing to mirror without --confirm-appropriate-mirror")
91
+ if not args.decision_note.strip():
92
+ raise SystemExit("--decision-note must explain why mirroring is appropriate")
93
+
94
+ run_dir = Path(args.run_dir).expanduser().resolve()
95
+ manifest_path = run_dir / "visual-jobs.json"
96
+ manifest = load_manifest(run_dir)
97
+ right_job = find_job(manifest, "running-right")
98
+ left_job = find_job(manifest, "running-left")
99
+
100
+ if right_job.get("status") != "complete":
101
+ raise SystemExit("running-right must be complete before deriving running-left")
102
+ mirror_policy = left_job.get("mirror_policy")
103
+ if (
104
+ not isinstance(mirror_policy, dict)
105
+ or mirror_policy.get("may_derive_from") != "running-right"
106
+ ):
107
+ raise SystemExit("running-left is not configured for conditional mirroring")
108
+
109
+ source = run_dir / "decoded" / "running-right.png"
110
+ output = run_dir / "decoded" / "running-left.png"
111
+ if not source.is_file():
112
+ raise SystemExit(f"running-right decoded strip not found: {source}")
113
+ if output.exists() and not args.force:
114
+ raise SystemExit(f"{output} already exists; pass --force to replace it")
115
+
116
+ output.parent.mkdir(parents=True, exist_ok=True)
117
+ with Image.open(source) as image:
118
+ mirrored = mirror_strip_preserving_frame_order(image)
119
+ mirrored.save(output)
120
+
121
+ derived_at = datetime.now(timezone.utc).isoformat()
122
+ left_job["status"] = "staged"
123
+ left_job["source_path"] = manifest_relative(output, run_dir)
124
+ left_job["derived_from"] = "running-right"
125
+ left_job["derived_from_source"] = manifest_relative(source, run_dir)
126
+ left_job["staged_at"] = derived_at
127
+ left_job["metadata"] = image_metadata(output)
128
+ left_job["mirror_decision"] = {
129
+ "approved": True,
130
+ "approved_at": derived_at,
131
+ "note": args.decision_note.strip(),
132
+ "transform": "framewise-horizontal-mirror-preserving-order",
133
+ }
134
+ left_job.pop("completed_at", None)
135
+ for key in [
136
+ "last_error",
137
+ "repair_reason",
138
+ "queued_at",
139
+ ]:
140
+ left_job.pop(key, None)
141
+
142
+ temporary = manifest_path.with_name(f".{manifest_path.name}.{os.getpid()}.tmp")
143
+ try:
144
+ temporary.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
145
+ temporary.replace(manifest_path)
146
+ finally:
147
+ temporary.unlink(missing_ok=True)
148
+ print(
149
+ json.dumps(
150
+ {
151
+ "ok": True,
152
+ "job_id": "running-left",
153
+ "derived_from": "running-right",
154
+ "output": str(output),
155
+ "decision_note": args.decision_note.strip(),
156
+ "transform": "framewise-horizontal-mirror-preserving-order",
157
+ },
158
+ indent=2,
159
+ )
160
+ )
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()