pi-sprite 1.0.0
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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +353 -0
- package/examples/README.md +15 -0
- package/examples/custom-pets/wumpus-template/README.md +21 -0
- package/examples/custom-pets/wumpus-template/pet.json +14 -0
- package/examples/petdex-downloads/.gitkeep +0 -0
- package/extensions/index.ts +104 -0
- package/package.json +77 -0
- package/skills/pi-sprite-authoring/SKILL.md +330 -0
- package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
- package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
- package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
- package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
- package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
- package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
- package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
- package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
- package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
- package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
- package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
- package/src/agent/session-entries.ts +28 -0
- package/src/agent/side-completion.ts +94 -0
- package/src/agent/side-session-text.ts +20 -0
- package/src/agent/side-session-types.ts +12 -0
- package/src/agent/side-session.ts +107 -0
- package/src/btw/completion.ts +15 -0
- package/src/btw/format.ts +30 -0
- package/src/btw/index.ts +306 -0
- package/src/btw/prompt.ts +35 -0
- package/src/btw/recap.ts +56 -0
- package/src/btw/session.ts +37 -0
- package/src/btw/thread-store.ts +37 -0
- package/src/context/index.ts +343 -0
- package/src/recap/conversation.ts +22 -0
- package/src/recap/direct.ts +15 -0
- package/src/recap/format.ts +25 -0
- package/src/recap/generation.ts +58 -0
- package/src/recap/index.ts +86 -0
- package/src/sprite/commands.ts +205 -0
- package/src/sprite/download.ts +75 -0
- package/src/sprite/kitty-placeholder.ts +441 -0
- package/src/sprite/live-status-format.ts +67 -0
- package/src/sprite/live-status.ts +48 -0
- package/src/sprite/loader.ts +134 -0
- package/src/sprite/manifest.ts +87 -0
- package/src/sprite/paths.ts +8 -0
- package/src/sprite/petdex.ts +133 -0
- package/src/sprite/renderer.ts +491 -0
- package/src/sprite/runtime.ts +524 -0
- package/src/sprite/turn-status-format.ts +112 -0
- package/src/sprite/turn-status.ts +88 -0
- package/src/ui/overlay.ts +308 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate pi-sprite pet images with OpenAI GPT Image.
|
|
3
|
+
|
|
4
|
+
The script intentionally stays small: one prompt in, optional reference images,
|
|
5
|
+
one generated sprite candidate out. Use --dry-run to validate prompt/reference
|
|
6
|
+
wiring and write metadata without requiring OPENAI_API_KEY or the openai package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
SUPPORTED_REFERENCE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
|
22
|
+
DEFAULT_MODEL = "gpt-image-2"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_args() -> argparse.Namespace:
|
|
26
|
+
parser = argparse.ArgumentParser(description="Generate a pi-sprite image with OpenAI GPT Image.")
|
|
27
|
+
prompt_group = parser.add_mutually_exclusive_group(required=True)
|
|
28
|
+
prompt_group.add_argument("--prompt", help="Prompt text to send to OpenAI")
|
|
29
|
+
prompt_group.add_argument("--prompt-file", type=Path, help="Path to a UTF-8 prompt file")
|
|
30
|
+
parser.add_argument("--reference-image", type=Path, action="append", default=[], help="Local reference image path; repeatable")
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--reference-role",
|
|
33
|
+
action="append",
|
|
34
|
+
default=[],
|
|
35
|
+
help="Role for a reference image, e.g. character_reference or style_reference. Repeat in --reference-image order.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--reference-instruction",
|
|
39
|
+
action="append",
|
|
40
|
+
default=[],
|
|
41
|
+
help="Instruction for a reference image; repeat in --reference-image order. One instruction may be reused for all references.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--output-dir", type=Path, default=Path("generated/pi-sprite"), help="Output directory")
|
|
44
|
+
parser.add_argument("--prefix", default="sprite", help="Output filename prefix")
|
|
45
|
+
parser.add_argument("--model", default=os.environ.get("OPENAI_IMAGE_MODEL", DEFAULT_MODEL), help="OpenAI image model")
|
|
46
|
+
parser.add_argument("--size", default="1024x1024", help="OpenAI image size")
|
|
47
|
+
parser.add_argument("--quality", default="low", help="OpenAI image quality")
|
|
48
|
+
parser.add_argument("--output-format", choices=["png", "jpeg", "webp"], default="png", help="Output image format")
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--background",
|
|
51
|
+
choices=["transparent", "opaque", "auto"],
|
|
52
|
+
default="auto",
|
|
53
|
+
help="OpenAI background setting. Default: auto. Use transparent only with models that support it.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--dry-run", action="store_true", help="Write prompt/metadata without calling OpenAI")
|
|
56
|
+
return parser.parse_args()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_prompt(args: argparse.Namespace) -> str:
|
|
60
|
+
if args.prompt is not None:
|
|
61
|
+
prompt = args.prompt.strip()
|
|
62
|
+
else:
|
|
63
|
+
prompt = args.prompt_file.read_text(encoding="utf-8").strip()
|
|
64
|
+
if not prompt:
|
|
65
|
+
raise ValueError("prompt cannot be empty")
|
|
66
|
+
return prompt
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def values_for_references(values: list[str], paths: list[Path], *, default: str, flag: str) -> list[str]:
|
|
70
|
+
if not paths:
|
|
71
|
+
return []
|
|
72
|
+
if not values:
|
|
73
|
+
return [default for _ in paths]
|
|
74
|
+
if len(values) == 1:
|
|
75
|
+
return [values[0] for _ in paths]
|
|
76
|
+
if len(values) != len(paths):
|
|
77
|
+
raise ValueError(f"{flag} must be supplied once or once per --reference-image")
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_references(paths: list[Path], instructions: list[str], roles: list[str]) -> list[dict[str, str]]:
|
|
82
|
+
resolved_instructions = values_for_references(instructions, paths, default="", flag="--reference-instruction")
|
|
83
|
+
resolved_roles = values_for_references(roles, paths, default="reference", flag="--reference-role")
|
|
84
|
+
if paths and any(not instruction.strip() for instruction in resolved_instructions):
|
|
85
|
+
raise ValueError("--reference-instruction is required when --reference-image is used")
|
|
86
|
+
references: list[dict[str, str]] = []
|
|
87
|
+
for index, path in enumerate(paths, start=1):
|
|
88
|
+
if path.suffix.lower() not in SUPPORTED_REFERENCE_SUFFIXES:
|
|
89
|
+
raise ValueError(f"unsupported reference image type: {path}")
|
|
90
|
+
if not path.exists():
|
|
91
|
+
raise ValueError(f"reference image missing: {path}")
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
raise ValueError(f"reference image is not a file: {path}")
|
|
94
|
+
if path.stat().st_size <= 0:
|
|
95
|
+
raise ValueError(f"reference image is empty: {path}")
|
|
96
|
+
references.append(
|
|
97
|
+
{
|
|
98
|
+
"id": f"reference-{index}",
|
|
99
|
+
"path": str(path),
|
|
100
|
+
"role": resolved_roles[index - 1].strip() or "reference",
|
|
101
|
+
"instruction": resolved_instructions[index - 1].strip(),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return references
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def openai_image_files(paths: list[Path]) -> list[tuple[str, bytes, str]]:
|
|
108
|
+
files: list[tuple[str, bytes, str]] = []
|
|
109
|
+
for path in paths:
|
|
110
|
+
mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
|
|
111
|
+
if mime_type == "image/jpg":
|
|
112
|
+
mime_type = "image/jpeg"
|
|
113
|
+
files.append((path.name, path.read_bytes(), mime_type))
|
|
114
|
+
return files
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def image_data_to_b64(item: Any) -> str | None:
|
|
118
|
+
if hasattr(item, "b64_json") and item.b64_json:
|
|
119
|
+
return item.b64_json
|
|
120
|
+
if isinstance(item, dict):
|
|
121
|
+
return item.get("b64_json") or item.get("result")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def prompt_with_reference_bindings(prompt: str, references: list[dict[str, str]]) -> str:
|
|
126
|
+
if not references:
|
|
127
|
+
return prompt
|
|
128
|
+
lines = [prompt, "", "REFERENCE IMAGE BINDINGS:"]
|
|
129
|
+
for index, reference in enumerate(references, start=1):
|
|
130
|
+
lines.append(f"{index}. {reference['role']}: {reference['instruction']}")
|
|
131
|
+
lines.append("")
|
|
132
|
+
lines.append("Use these bindings when interpreting the attached images. Preserve character_reference identity exactly; use style_reference, scale_reference, plush_reference, modern_style_reference, and other non-character references only for their stated instruction.")
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def generate_image(args: argparse.Namespace, prompt: str, references: list[dict[str, str]]) -> tuple[bytes, str | None]:
|
|
137
|
+
if not os.environ.get("OPENAI_API_KEY"):
|
|
138
|
+
raise RuntimeError("OPENAI_API_KEY is required unless --dry-run is set")
|
|
139
|
+
try:
|
|
140
|
+
from openai import OpenAI
|
|
141
|
+
except ImportError as exc:
|
|
142
|
+
raise RuntimeError("Python package 'openai' is required. Run with: uv run --with openai python ...") from exc
|
|
143
|
+
|
|
144
|
+
client = OpenAI()
|
|
145
|
+
kwargs: dict[str, Any] = {
|
|
146
|
+
"model": args.model,
|
|
147
|
+
"prompt": prompt_with_reference_bindings(prompt, references),
|
|
148
|
+
"size": args.size,
|
|
149
|
+
"quality": args.quality,
|
|
150
|
+
"output_format": args.output_format,
|
|
151
|
+
"background": args.background,
|
|
152
|
+
}
|
|
153
|
+
if args.reference_image:
|
|
154
|
+
image_files = openai_image_files(args.reference_image)
|
|
155
|
+
result = client.images.edit(image=image_files if len(image_files) > 1 else image_files[0], **kwargs)
|
|
156
|
+
method = "edit"
|
|
157
|
+
else:
|
|
158
|
+
result = client.images.generate(**kwargs)
|
|
159
|
+
method = "generate"
|
|
160
|
+
|
|
161
|
+
data = getattr(result, "data", None) or []
|
|
162
|
+
if not data:
|
|
163
|
+
raise RuntimeError("OpenAI response did not include image data")
|
|
164
|
+
b64 = image_data_to_b64(data[0])
|
|
165
|
+
if not b64:
|
|
166
|
+
raise RuntimeError("OpenAI response did not include b64 image data")
|
|
167
|
+
return base64.b64decode(b64), method
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_outputs(args: argparse.Namespace, prompt: str, references: list[dict[str, str]]) -> dict[str, Any]:
|
|
171
|
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
173
|
+
base = args.output_dir / f"{args.prefix}-{stamp}"
|
|
174
|
+
method = "edit" if args.reference_image else "generate"
|
|
175
|
+
image_path: Path | None = None
|
|
176
|
+
if not args.dry_run:
|
|
177
|
+
image_bytes, method = generate_image(args, prompt, references)
|
|
178
|
+
image_path = base.with_suffix(f".{args.output_format}")
|
|
179
|
+
image_path.write_bytes(image_bytes)
|
|
180
|
+
|
|
181
|
+
effective_prompt = prompt_with_reference_bindings(prompt, references)
|
|
182
|
+
prompt_path = base.with_suffix(".prompt.txt")
|
|
183
|
+
prompt_path.write_text(effective_prompt + "\n", encoding="utf-8")
|
|
184
|
+
metadata = {
|
|
185
|
+
"dry_run": args.dry_run,
|
|
186
|
+
"method": method,
|
|
187
|
+
"model": args.model,
|
|
188
|
+
"size": args.size,
|
|
189
|
+
"quality": args.quality,
|
|
190
|
+
"output_format": args.output_format,
|
|
191
|
+
"background": args.background,
|
|
192
|
+
"prompt_path": str(prompt_path),
|
|
193
|
+
"reference_bindings_in_prompt": bool(references),
|
|
194
|
+
"image_path": str(image_path) if image_path else None,
|
|
195
|
+
"references": references,
|
|
196
|
+
}
|
|
197
|
+
metadata_path = base.with_suffix(".metadata.json")
|
|
198
|
+
metadata["metadata_path"] = str(metadata_path)
|
|
199
|
+
metadata_path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
200
|
+
return metadata
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def main() -> int:
|
|
204
|
+
try:
|
|
205
|
+
args = parse_args()
|
|
206
|
+
prompt = load_prompt(args)
|
|
207
|
+
references = validate_references(args.reference_image, args.reference_instruction, args.reference_role)
|
|
208
|
+
metadata = write_outputs(args, prompt, references)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
211
|
+
return 2
|
|
212
|
+
|
|
213
|
+
print(f"dry_run: {metadata['dry_run']}")
|
|
214
|
+
print(f"method: {metadata['method']}")
|
|
215
|
+
print(f"prompt: {metadata['prompt_path']}")
|
|
216
|
+
print(f"metadata: {metadata['metadata_path']}")
|
|
217
|
+
if metadata["image_path"]:
|
|
218
|
+
print(f"image: {metadata['image_path']}")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remove edge-connected flat/checker backgrounds from generated pi-sprite images.
|
|
3
|
+
|
|
4
|
+
This is a local post-processing helper. It does not call network APIs. It uses
|
|
5
|
+
Pillow to make edge-connected background pixels transparent, then optionally
|
|
6
|
+
normalizes the sprite onto a square canvas for pi-sprite imports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import sys
|
|
15
|
+
from collections import deque
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_color(value: str) -> tuple[int, int, int]:
|
|
21
|
+
text = value.strip().removeprefix("#")
|
|
22
|
+
if len(text) != 6:
|
|
23
|
+
raise argparse.ArgumentTypeError("colors must be 6-digit hex, e.g. #ffffff")
|
|
24
|
+
try:
|
|
25
|
+
return (int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))
|
|
26
|
+
except ValueError as exc:
|
|
27
|
+
raise argparse.ArgumentTypeError("colors must be 6-digit hex, e.g. #ffffff") from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_args() -> argparse.Namespace:
|
|
31
|
+
parser = argparse.ArgumentParser(description="Remove an edge-connected background from a generated pi-sprite image.")
|
|
32
|
+
parser.add_argument("--input", type=Path, required=True, help="Input PNG/JPEG/WebP image")
|
|
33
|
+
parser.add_argument("--output", type=Path, required=True, help="Output PNG path")
|
|
34
|
+
parser.add_argument("--metadata", type=Path, help="Optional metadata JSON path")
|
|
35
|
+
parser.add_argument("--target-size", type=int, default=128, help="Final square canvas size in pixels; use 0 to preserve size")
|
|
36
|
+
parser.add_argument("--padding", type=int, default=10, help="Padding around cropped sprite when target-size is set")
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--threshold",
|
|
39
|
+
type=float,
|
|
40
|
+
default=38.0,
|
|
41
|
+
help="RGB distance threshold for background matching. Raise for checker/near-white backgrounds.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--background-color",
|
|
45
|
+
type=parse_color,
|
|
46
|
+
action="append",
|
|
47
|
+
default=[],
|
|
48
|
+
help="Background color to remove, as #RRGGBB. Repeatable. Defaults to sampled edge colors.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--dry-run", action="store_true", help="Write metadata only; do not write the output image")
|
|
51
|
+
return parser.parse_args()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def color_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
|
|
55
|
+
return math.sqrt(sum((a[index] - b[index]) ** 2 for index in range(3)))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def close_to_any(color: tuple[int, int, int], samples: list[tuple[int, int, int]], threshold: float) -> bool:
|
|
59
|
+
return any(color_distance(color, sample) <= threshold for sample in samples)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def edge_positions(width: int, height: int) -> list[tuple[int, int]]:
|
|
63
|
+
positions: list[tuple[int, int]] = []
|
|
64
|
+
for x in range(width):
|
|
65
|
+
positions.append((x, 0))
|
|
66
|
+
positions.append((x, height - 1))
|
|
67
|
+
for y in range(1, height - 1):
|
|
68
|
+
positions.append((0, y))
|
|
69
|
+
positions.append((width - 1, y))
|
|
70
|
+
return positions
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sampled_edge_colors(image: Any, explicit: list[tuple[int, int, int]]) -> list[tuple[int, int, int]]:
|
|
74
|
+
if explicit:
|
|
75
|
+
return explicit
|
|
76
|
+
width, height = image.size
|
|
77
|
+
candidates = [
|
|
78
|
+
(0, 0),
|
|
79
|
+
(width - 1, 0),
|
|
80
|
+
(0, height - 1),
|
|
81
|
+
(width - 1, height - 1),
|
|
82
|
+
(width // 2, 0),
|
|
83
|
+
(width // 2, height - 1),
|
|
84
|
+
(0, height // 2),
|
|
85
|
+
(width - 1, height // 2),
|
|
86
|
+
]
|
|
87
|
+
seen: set[tuple[int, int, int]] = set()
|
|
88
|
+
samples: list[tuple[int, int, int]] = []
|
|
89
|
+
pixels = image.load()
|
|
90
|
+
for pos in candidates:
|
|
91
|
+
pixel = pixels[pos[0], pos[1]]
|
|
92
|
+
color = (pixel[0], pixel[1], pixel[2])
|
|
93
|
+
if color not in seen:
|
|
94
|
+
seen.add(color)
|
|
95
|
+
samples.append(color)
|
|
96
|
+
return samples
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def transparent_background_mask(image: Any, samples: list[tuple[int, int, int]], threshold: float) -> set[tuple[int, int]]:
|
|
100
|
+
width, height = image.size
|
|
101
|
+
pixels = image.load()
|
|
102
|
+
visited: set[tuple[int, int]] = set()
|
|
103
|
+
queue: deque[tuple[int, int]] = deque()
|
|
104
|
+
|
|
105
|
+
for position in edge_positions(width, height):
|
|
106
|
+
pixel = pixels[position[0], position[1]]
|
|
107
|
+
if pixel[3] == 0 or close_to_any((pixel[0], pixel[1], pixel[2]), samples, threshold):
|
|
108
|
+
visited.add(position)
|
|
109
|
+
queue.append(position)
|
|
110
|
+
|
|
111
|
+
while queue:
|
|
112
|
+
x, y = queue.popleft()
|
|
113
|
+
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
|
|
114
|
+
if nx < 0 or ny < 0 or nx >= width or ny >= height or (nx, ny) in visited:
|
|
115
|
+
continue
|
|
116
|
+
pixel = pixels[nx, ny]
|
|
117
|
+
if pixel[3] == 0 or close_to_any((pixel[0], pixel[1], pixel[2]), samples, threshold):
|
|
118
|
+
visited.add((nx, ny))
|
|
119
|
+
queue.append((nx, ny))
|
|
120
|
+
return visited
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_canvas(image: Any, target_size: int, padding: int) -> tuple[Any, tuple[int, int, int, int] | None]:
|
|
124
|
+
if target_size <= 0:
|
|
125
|
+
return image, image.getbbox()
|
|
126
|
+
bbox = image.getbbox()
|
|
127
|
+
if bbox is None:
|
|
128
|
+
return image.resize((target_size, target_size)), None
|
|
129
|
+
|
|
130
|
+
from PIL import Image
|
|
131
|
+
|
|
132
|
+
cropped = image.crop(bbox)
|
|
133
|
+
max_sprite_size = max(1, target_size - 2 * padding)
|
|
134
|
+
scale = min(max_sprite_size / cropped.width, max_sprite_size / cropped.height, 1.0)
|
|
135
|
+
resized = cropped.resize((max(1, round(cropped.width * scale)), max(1, round(cropped.height * scale))), Image.Resampling.LANCZOS)
|
|
136
|
+
canvas = Image.new("RGBA", (target_size, target_size), (0, 0, 0, 0))
|
|
137
|
+
left = (target_size - resized.width) // 2
|
|
138
|
+
top = target_size - padding - resized.height
|
|
139
|
+
canvas.alpha_composite(resized, (left, max(0, top)))
|
|
140
|
+
return canvas, bbox
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def process(args: argparse.Namespace) -> dict[str, Any]:
|
|
144
|
+
if not args.input.exists():
|
|
145
|
+
raise ValueError(f"input image missing: {args.input}")
|
|
146
|
+
if args.target_size < 0:
|
|
147
|
+
raise ValueError("--target-size must be >= 0")
|
|
148
|
+
if args.padding < 0:
|
|
149
|
+
raise ValueError("--padding must be >= 0")
|
|
150
|
+
|
|
151
|
+
from PIL import Image
|
|
152
|
+
|
|
153
|
+
original = Image.open(args.input)
|
|
154
|
+
image = original.convert("RGBA")
|
|
155
|
+
width, height = image.size
|
|
156
|
+
samples = sampled_edge_colors(image, args.background_color)
|
|
157
|
+
background_pixels = transparent_background_mask(image, samples, args.threshold)
|
|
158
|
+
|
|
159
|
+
pixels = image.load()
|
|
160
|
+
for x, y in background_pixels:
|
|
161
|
+
r, g, b, _ = pixels[x, y]
|
|
162
|
+
pixels[x, y] = (r, g, b, 0)
|
|
163
|
+
|
|
164
|
+
normalized, bbox = normalize_canvas(image, args.target_size, args.padding)
|
|
165
|
+
if not args.dry_run:
|
|
166
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
normalized.save(args.output, format="PNG")
|
|
168
|
+
|
|
169
|
+
metadata = {
|
|
170
|
+
"input": str(args.input),
|
|
171
|
+
"output": str(args.output),
|
|
172
|
+
"dry_run": args.dry_run,
|
|
173
|
+
"original_mode": original.mode,
|
|
174
|
+
"original_size": [width, height],
|
|
175
|
+
"target_size": args.target_size,
|
|
176
|
+
"padding": args.padding,
|
|
177
|
+
"threshold": args.threshold,
|
|
178
|
+
"background_samples": [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in samples],
|
|
179
|
+
"removed_edge_connected_pixels": len(background_pixels),
|
|
180
|
+
"nontransparent_bbox": list(bbox) if bbox else None,
|
|
181
|
+
"output_size": list(normalized.size),
|
|
182
|
+
"has_alpha": True,
|
|
183
|
+
}
|
|
184
|
+
if args.metadata:
|
|
185
|
+
args.metadata.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
187
|
+
return metadata
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main() -> int:
|
|
191
|
+
try:
|
|
192
|
+
args = parse_args()
|
|
193
|
+
metadata = process(args)
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
196
|
+
return 2
|
|
197
|
+
|
|
198
|
+
print(f"input: {metadata['input']}")
|
|
199
|
+
print(f"output: {metadata['output']}")
|
|
200
|
+
print(f"dry_run: {metadata['dry_run']}")
|
|
201
|
+
print(f"removed_edge_connected_pixels: {metadata['removed_edge_connected_pixels']}")
|
|
202
|
+
print(f"output_size: {metadata['output_size']}")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const BTW_ENTRY = "pi-sprite:btw-entry";
|
|
4
|
+
export const BTW_RESET = "pi-sprite:btw-reset";
|
|
5
|
+
export const RECAP_ENTRY = "pi-sprite:recap";
|
|
6
|
+
|
|
7
|
+
const HIDDEN_CONTEXT_CUSTOM_TYPES = new Set([BTW_ENTRY, BTW_RESET, RECAP_ENTRY]);
|
|
8
|
+
|
|
9
|
+
export function customTypeOf(entry: unknown): string | undefined {
|
|
10
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
11
|
+
const typed = entry as { customType?: unknown };
|
|
12
|
+
return typeof typed.customType === "string" ? typed.customType : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPiSpriteHiddenContextEntry(entry: unknown): boolean {
|
|
16
|
+
const customType = customTypeOf(entry);
|
|
17
|
+
return Boolean(customType && HIDDEN_CONTEXT_CUSTOM_TYPES.has(customType));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function filterPiSpriteHiddenContextEntries<T>(entries: T[]): T[] {
|
|
21
|
+
return entries.filter((entry) => !isPiSpriteHiddenContextEntry(entry));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerPiSpriteContextFilter(pi: ExtensionAPI): void {
|
|
25
|
+
(
|
|
26
|
+
pi as { on: (event: string, handler: (event: { messages: unknown[] }) => Promise<{ messages: unknown[] }>) => void }
|
|
27
|
+
).on("context", async (event) => ({ messages: filterPiSpriteHiddenContextEntries(event.messages) }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Message, TextContent } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { SideCompletionRequest, SideCompletionResult } from "./side-session-types.ts";
|
|
4
|
+
|
|
5
|
+
type CompletionContext = ExtensionCommandContext | ExtensionContext;
|
|
6
|
+
|
|
7
|
+
type DirectCompletionResult = { ok: true; text: string } | { ok: false; message: string };
|
|
8
|
+
|
|
9
|
+
export async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
10
|
+
return await new Promise<T | undefined>((resolve) => {
|
|
11
|
+
const timer = setTimeout(() => resolve(undefined), ms);
|
|
12
|
+
promise.then(
|
|
13
|
+
(value) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
resolve(value);
|
|
16
|
+
},
|
|
17
|
+
() => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve(undefined);
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cappedModel<T extends { maxTokens?: number }>(model: T, maxTokens: number): T {
|
|
26
|
+
return {
|
|
27
|
+
...model,
|
|
28
|
+
maxTokens: Math.min(model.maxTokens ?? maxTokens, maxTokens),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function completeWithApiKeyText(
|
|
33
|
+
ctx: CompletionContext,
|
|
34
|
+
prompt: string,
|
|
35
|
+
options: { maxTokens: number; timeoutMs?: number; systemPrompt?: string },
|
|
36
|
+
): Promise<DirectCompletionResult> {
|
|
37
|
+
if (!ctx.model) return { ok: false, message: "No active model selected." };
|
|
38
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
39
|
+
if (!auth.ok || !auth.apiKey) return { ok: false, message: auth.ok ? "No API key available." : auth.error };
|
|
40
|
+
const request = async () => {
|
|
41
|
+
const messages: Message[] = [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }];
|
|
42
|
+
const { complete } = await import("@earendil-works/pi-ai/compat");
|
|
43
|
+
const response = await complete(
|
|
44
|
+
cappedModel(ctx.model!, options.maxTokens),
|
|
45
|
+
{ systemPrompt: options.systemPrompt, messages },
|
|
46
|
+
{ apiKey: auth.apiKey, headers: auth.headers, maxTokens: options.maxTokens },
|
|
47
|
+
);
|
|
48
|
+
return response.content
|
|
49
|
+
.filter((part): part is TextContent => part.type === "text")
|
|
50
|
+
.map((part) => part.text)
|
|
51
|
+
.join("\n")
|
|
52
|
+
.trim();
|
|
53
|
+
};
|
|
54
|
+
const text = options.timeoutMs ? await withTimeout(request(), options.timeoutMs) : await request();
|
|
55
|
+
if (!text)
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
message: options.timeoutMs
|
|
59
|
+
? "Direct API-key fallback timed out or returned no text."
|
|
60
|
+
: "Direct API-key fallback returned no text.",
|
|
61
|
+
};
|
|
62
|
+
return { ok: true, text };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function completeTextWithSideSessionFallback(
|
|
66
|
+
ctx: CompletionContext,
|
|
67
|
+
request: SideCompletionRequest,
|
|
68
|
+
adapters: {
|
|
69
|
+
sideSession: (ctx: CompletionContext, request: SideCompletionRequest) => Promise<SideCompletionResult>;
|
|
70
|
+
direct: (
|
|
71
|
+
ctx: CompletionContext,
|
|
72
|
+
prompt: string,
|
|
73
|
+
maxTokens: number,
|
|
74
|
+
) => Promise<string | { ok: false; message: string } | undefined>;
|
|
75
|
+
},
|
|
76
|
+
): Promise<
|
|
77
|
+
| { ok: true; text: string; source: "side-session" | "api-key-fallback" }
|
|
78
|
+
| { ok: false; sideResult: Extract<SideCompletionResult, { ok: false }>; directMessage: string; message: string }
|
|
79
|
+
> {
|
|
80
|
+
const sideResult = await adapters.sideSession(ctx, request);
|
|
81
|
+
if (sideResult.ok) return { ok: true, text: sideResult.text, source: "side-session" };
|
|
82
|
+
const directResult = await adapters.direct(ctx, request.prompt, request.maxTokens ?? 1200);
|
|
83
|
+
if (typeof directResult === "string" && directResult) {
|
|
84
|
+
return { ok: true, text: directResult, source: "api-key-fallback" };
|
|
85
|
+
}
|
|
86
|
+
const directMessage =
|
|
87
|
+
typeof directResult === "object" ? directResult.message : "Direct API-key fallback returned no text.";
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
sideResult,
|
|
91
|
+
directMessage,
|
|
92
|
+
message: "Side session and direct API-key fallback returned no text.",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function extractAssistantText(messages: unknown[]): string {
|
|
2
|
+
for (const message of [...messages].reverse()) {
|
|
3
|
+
const role = (message as { role?: string })?.role;
|
|
4
|
+
if (role !== "assistant") continue;
|
|
5
|
+
const content = (message as { content?: unknown })?.content;
|
|
6
|
+
if (typeof content === "string") return content.trim();
|
|
7
|
+
if (!Array.isArray(content)) continue;
|
|
8
|
+
const text = content
|
|
9
|
+
.map((part) =>
|
|
10
|
+
part && typeof part === "object" && (part as { type?: string }).type === "text"
|
|
11
|
+
? ((part as { text?: string }).text ?? "")
|
|
12
|
+
: "",
|
|
13
|
+
)
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join("\n")
|
|
16
|
+
.trim();
|
|
17
|
+
if (text) return text;
|
|
18
|
+
}
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SideCompletionRequest = {
|
|
2
|
+
prompt: string;
|
|
3
|
+
systemPrompt?: string;
|
|
4
|
+
maxTokens?: number;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type SideCompletionFailureReason = "no-model" | "timeout" | "empty" | "error";
|
|
9
|
+
|
|
10
|
+
export type SideCompletionResult =
|
|
11
|
+
| { ok: true; text: string }
|
|
12
|
+
| { ok: false; reason: SideCompletionFailureReason; message: string };
|