castletool 0.2.2__tar.gz → 0.3.0__tar.gz
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.
- {castletool-0.2.2 → castletool-0.3.0}/PKG-INFO +1 -1
- {castletool-0.2.2 → castletool-0.3.0}/castletool/castletool.py +586 -180
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/PKG-INFO +1 -1
- {castletool-0.2.2 → castletool-0.3.0}/pyproject.toml +1 -1
- {castletool-0.2.2 → castletool-0.3.0}/LICENSE +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/README.md +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool/__init__.py +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/SOURCES.txt +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/dependency_links.txt +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/entry_points.txt +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/requires.txt +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/castletool.egg-info/top_level.txt +0 -0
- {castletool-0.2.2 → castletool-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: castletool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -9,14 +9,21 @@ import bisect
|
|
|
9
9
|
import copy
|
|
10
10
|
import io
|
|
11
11
|
import json
|
|
12
|
+
import math
|
|
12
13
|
import os
|
|
13
14
|
import platform
|
|
15
|
+
import re
|
|
14
16
|
import shutil
|
|
15
17
|
import subprocess
|
|
16
18
|
import sys
|
|
19
|
+
import urllib.request
|
|
17
20
|
import uuid
|
|
21
|
+
import xml.etree.ElementTree as ET
|
|
18
22
|
from pathlib import Path
|
|
19
23
|
|
|
24
|
+
CURRENT_VERSION = "0.3.0"
|
|
25
|
+
PYPI_URL = "https://pypi.org/pypi/castletool/json"
|
|
26
|
+
|
|
20
27
|
# ── optional deps ────────────────────────────────────────────────────────────
|
|
21
28
|
try:
|
|
22
29
|
from PIL import Image
|
|
@@ -30,21 +37,6 @@ try:
|
|
|
30
37
|
except ImportError:
|
|
31
38
|
HAS_MIDO = False
|
|
32
39
|
|
|
33
|
-
try:
|
|
34
|
-
from prompt_toolkit import prompt as pt_prompt
|
|
35
|
-
from prompt_toolkit.completion import PathCompleter
|
|
36
|
-
from prompt_toolkit.styles import Style
|
|
37
|
-
HAS_PT = True
|
|
38
|
-
except ImportError:
|
|
39
|
-
HAS_PT = False
|
|
40
|
-
|
|
41
|
-
HAS_FZF = shutil.which("fzf") is not None
|
|
42
|
-
|
|
43
|
-
try:
|
|
44
|
-
from svg_to_castle import svg_to_path_data, build_drawing2_vector
|
|
45
|
-
HAS_SVG = True
|
|
46
|
-
except ImportError:
|
|
47
|
-
HAS_SVG = False
|
|
48
40
|
HAS_FFMPEG = shutil.which("ffmpeg") is not None
|
|
49
41
|
|
|
50
42
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -71,8 +63,14 @@ def ask(prompt, default=None):
|
|
|
71
63
|
return val if val else (default or "")
|
|
72
64
|
|
|
73
65
|
def yn(prompt, default="y"):
|
|
66
|
+
hint = "Y/n" if default == "y" else "y/N"
|
|
74
67
|
while True:
|
|
75
|
-
|
|
68
|
+
try:
|
|
69
|
+
ans = input(f"{BOLD}{prompt} ({hint}){RESET} ").strip().lower()
|
|
70
|
+
except (EOFError, KeyboardInterrupt):
|
|
71
|
+
p(); sys.exit(0)
|
|
72
|
+
if ans == "":
|
|
73
|
+
return default == "y"
|
|
76
74
|
if ans in ("y", "n"):
|
|
77
75
|
return ans == "y"
|
|
78
76
|
p("Please enter y or n.")
|
|
@@ -89,60 +87,14 @@ def choose(prompt, options):
|
|
|
89
87
|
return options[int(raw) - 1]
|
|
90
88
|
p("Invalid choice.")
|
|
91
89
|
|
|
92
|
-
def fzf_pick_file(start_dir: Path) -> str | None:
|
|
93
|
-
"""Open fzf to fuzzy-find a file under start_dir. Returns path string or None."""
|
|
94
|
-
try:
|
|
95
|
-
# find all files under start_dir, pipe into fzf
|
|
96
|
-
find = subprocess.run(
|
|
97
|
-
["find", str(start_dir), "-type", "f"],
|
|
98
|
-
capture_output=True, text=True
|
|
99
|
-
)
|
|
100
|
-
fzf = subprocess.run(
|
|
101
|
-
["fzf", "--prompt", "Select file> ", "--height", "40%",
|
|
102
|
-
"--layout", "reverse", "--border"],
|
|
103
|
-
input=find.stdout,
|
|
104
|
-
capture_output=True, text=True
|
|
105
|
-
)
|
|
106
|
-
result = fzf.stdout.strip()
|
|
107
|
-
return result if result else None
|
|
108
|
-
except Exception:
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
|
|
112
90
|
def ask_path(prompt, default=None, search_dir: Path | None = None):
|
|
113
|
-
"""
|
|
114
|
-
File path input. Priority:
|
|
115
|
-
1. fzf fuzzy picker (if fzf installed)
|
|
116
|
-
2. prompt_toolkit tab-complete (if installed)
|
|
117
|
-
3. plain input fallback
|
|
118
|
-
"""
|
|
91
|
+
"""Plain file path input."""
|
|
119
92
|
suffix = f" [{default}]" if default is not None else ""
|
|
120
93
|
pb(prompt + suffix)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
picked = fzf_pick_file(start)
|
|
126
|
-
if picked:
|
|
127
|
-
ps(f"Selected: {picked}")
|
|
128
|
-
return picked
|
|
129
|
-
pw("fzf cancelled, falling back to manual input.")
|
|
130
|
-
|
|
131
|
-
if HAS_PT:
|
|
132
|
-
try:
|
|
133
|
-
from prompt_toolkit.formatted_text import FormattedText
|
|
134
|
-
val = pt_prompt(
|
|
135
|
-
FormattedText([("bold", "Path: ")]),
|
|
136
|
-
completer=PathCompleter(expanduser=True),
|
|
137
|
-
complete_while_typing=False,
|
|
138
|
-
).strip()
|
|
139
|
-
except (EOFError, KeyboardInterrupt):
|
|
140
|
-
p(); sys.exit(0)
|
|
141
|
-
else:
|
|
142
|
-
try:
|
|
143
|
-
val = input(f"{BOLD}Path: {RESET}").strip()
|
|
144
|
-
except (EOFError, KeyboardInterrupt):
|
|
145
|
-
p(); sys.exit(0)
|
|
94
|
+
try:
|
|
95
|
+
val = input(f"{BOLD}Path: {RESET}").strip()
|
|
96
|
+
except (EOFError, KeyboardInterrupt):
|
|
97
|
+
p(); sys.exit(0)
|
|
146
98
|
return val if val else (default or "")
|
|
147
99
|
|
|
148
100
|
|
|
@@ -157,17 +109,82 @@ def resolve_path(raw: str) -> Path:
|
|
|
157
109
|
def is_termux() -> bool:
|
|
158
110
|
return "com.termux" in str(Path.home()) or os.environ.get("TERMUX_VERSION") is not None
|
|
159
111
|
|
|
160
|
-
|
|
161
|
-
return (Path.home() / "storage").exists()
|
|
112
|
+
# ── update check ─────────────────────────────────────────────────────────────
|
|
162
113
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
114
|
+
def _version_tuple(v: str) -> tuple:
|
|
115
|
+
parts = []
|
|
116
|
+
for x in v.split("."):
|
|
117
|
+
num = "".join(ch for ch in x if ch.isdigit())
|
|
118
|
+
parts.append(int(num) if num else 0)
|
|
119
|
+
return tuple(parts)
|
|
120
|
+
|
|
121
|
+
def get_installed_version() -> str:
|
|
122
|
+
try:
|
|
123
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
124
|
+
try:
|
|
125
|
+
return version("castletool")
|
|
126
|
+
except PackageNotFoundError:
|
|
127
|
+
return CURRENT_VERSION
|
|
128
|
+
except ImportError:
|
|
129
|
+
return CURRENT_VERSION
|
|
130
|
+
|
|
131
|
+
def check_for_update():
|
|
132
|
+
"""Best-effort check against PyPI. Never blocks or errors on failure."""
|
|
133
|
+
current = get_installed_version()
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(PYPI_URL, timeout=3) as resp:
|
|
136
|
+
latest = json.loads(resp.read())["info"]["version"]
|
|
137
|
+
except Exception:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if _version_tuple(latest) > _version_tuple(current):
|
|
141
|
+
pw(f"A newer version is available: {latest} (you have {current})")
|
|
142
|
+
if yn("Install it now?"):
|
|
143
|
+
subprocess.run([sys.executable, "-m", "pip", "install",
|
|
144
|
+
"--quiet", "--upgrade", "castletool"])
|
|
145
|
+
ps("Updated! Please restart Castletool.")
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
p()
|
|
148
|
+
|
|
149
|
+
# ── dependency auto-install ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def ensure_dependencies():
|
|
152
|
+
"""Best-effort auto-install of missing dependencies."""
|
|
153
|
+
global HAS_PIL, HAS_MIDO, HAS_FFMPEG, Image, mido
|
|
154
|
+
|
|
155
|
+
pip_missing = []
|
|
156
|
+
if not HAS_PIL: pip_missing.append("Pillow")
|
|
157
|
+
if not HAS_MIDO: pip_missing.append("mido")
|
|
158
|
+
|
|
159
|
+
if pip_missing:
|
|
160
|
+
pb(f"Installing {', '.join(pip_missing)}...")
|
|
161
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", *pip_missing])
|
|
162
|
+
|
|
163
|
+
if not HAS_FFMPEG:
|
|
164
|
+
pb("Installing ffmpeg...")
|
|
165
|
+
if is_termux():
|
|
166
|
+
subprocess.run(["pkg", "install", "-y", "ffmpeg"])
|
|
167
|
+
elif shutil.which("apt-get"):
|
|
168
|
+
subprocess.run(["sudo", "apt-get", "install", "-y", "ffmpeg"])
|
|
169
|
+
elif shutil.which("brew"):
|
|
170
|
+
subprocess.run(["brew", "install", "ffmpeg"])
|
|
171
|
+
else:
|
|
172
|
+
pw("Could not auto-install ffmpeg. Please install it manually.")
|
|
173
|
+
|
|
174
|
+
if pip_missing or not HAS_FFMPEG:
|
|
175
|
+
try:
|
|
176
|
+
from PIL import Image as _Image
|
|
177
|
+
Image = _Image
|
|
178
|
+
HAS_PIL = True
|
|
179
|
+
except ImportError:
|
|
180
|
+
HAS_PIL = False
|
|
181
|
+
try:
|
|
182
|
+
import mido as _mido
|
|
183
|
+
mido = _mido
|
|
184
|
+
HAS_MIDO = True
|
|
185
|
+
except ImportError:
|
|
186
|
+
HAS_MIDO = False
|
|
187
|
+
HAS_FFMPEG = shutil.which("ffmpeg") is not None
|
|
171
188
|
|
|
172
189
|
# ── deck / card / blueprint discovery ────────────────────────────────────────
|
|
173
190
|
|
|
@@ -190,24 +207,23 @@ def find_blueprints(card: Path) -> list[Path]:
|
|
|
190
207
|
|
|
191
208
|
# ── image/gif → Drawing2 ─────────────────────────────────────────────────────
|
|
192
209
|
|
|
193
|
-
def load_image_frames(path: Path,
|
|
210
|
+
def load_image_frames(path: Path, width: int, height: int):
|
|
194
211
|
img = Image.open(path)
|
|
195
|
-
resample = Image.NEAREST if size > max(img.size) else Image.LANCZOS
|
|
196
212
|
|
|
197
213
|
# Normal image
|
|
198
214
|
if not getattr(img, "is_animated", False):
|
|
199
|
-
frame = img.convert("RGBA").resize((
|
|
215
|
+
frame = img.convert("RGBA").resize((width, height), Image.NEAREST)
|
|
200
216
|
buf = io.BytesIO()
|
|
201
217
|
frame.save(buf, format="PNG")
|
|
202
218
|
return [buf.getvalue()], 1.0
|
|
203
219
|
|
|
204
|
-
# Animated GIF
|
|
220
|
+
# Animated GIF/WEBP
|
|
205
221
|
frames = []
|
|
206
222
|
durations = []
|
|
207
223
|
|
|
208
224
|
try:
|
|
209
225
|
while True:
|
|
210
|
-
frame = img.convert("RGBA").resize((
|
|
226
|
+
frame = img.convert("RGBA").resize((width, height), Image.NEAREST)
|
|
211
227
|
buf = io.BytesIO()
|
|
212
228
|
frame.save(buf, format="PNG")
|
|
213
229
|
frames.append(buf.getvalue())
|
|
@@ -221,8 +237,8 @@ def load_image_frames(path: Path, size: int):
|
|
|
221
237
|
|
|
222
238
|
return frames, fps
|
|
223
239
|
|
|
224
|
-
def
|
|
225
|
-
"""Return
|
|
240
|
+
def probe_video_dimensions(path: Path) -> tuple[int, int]:
|
|
241
|
+
"""Return (width, height) of a video file via ffprobe."""
|
|
226
242
|
probe = subprocess.run(
|
|
227
243
|
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
|
228
244
|
"-show_entries", "stream=width,height",
|
|
@@ -232,12 +248,12 @@ def probe_video_size(path: Path) -> int:
|
|
|
232
248
|
raw = probe.stdout.strip()
|
|
233
249
|
try:
|
|
234
250
|
w, h = raw.split("x")
|
|
235
|
-
return
|
|
251
|
+
return int(w), int(h)
|
|
236
252
|
except Exception:
|
|
237
|
-
return 64
|
|
253
|
+
return 64, 64
|
|
238
254
|
|
|
239
255
|
|
|
240
|
-
def extract_mp4_frames(path: Path,
|
|
256
|
+
def extract_mp4_frames(path: Path, width: int, height: int, every_n: int = 1) -> tuple[list[bytes], float]:
|
|
241
257
|
"""Extract frames from MP4 using ffmpeg. Returns (png_frames, fps)."""
|
|
242
258
|
if not HAS_FFMPEG:
|
|
243
259
|
pe("ffmpeg not found. Install with: pkg install ffmpeg")
|
|
@@ -259,13 +275,12 @@ def extract_mp4_frames(path: Path, size: int, every_n: int = 1) -> tuple[list[by
|
|
|
259
275
|
|
|
260
276
|
effective_fps = fps / every_n
|
|
261
277
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
# Extract frames as PNG via pipe
|
|
278
|
+
# Extract frames as PNG via pipe. width/height already share the source's
|
|
279
|
+
# aspect ratio (scaled), so a plain nearest-neighbor scale is exact — no
|
|
280
|
+
# padding/cropping needed.
|
|
266
281
|
result = subprocess.run(
|
|
267
282
|
["ffmpeg", "-i", str(path),
|
|
268
|
-
"-vf", f"select='not(mod(n\\,{every_n}))',scale={
|
|
283
|
+
"-vf", f"select='not(mod(n\\,{every_n}))',scale={width}:{height}:flags=neighbor",
|
|
269
284
|
"-vsync", "vfr",
|
|
270
285
|
"-f", "image2pipe", "-vcodec", "png", "-"],
|
|
271
286
|
capture_output=True
|
|
@@ -303,24 +318,22 @@ def quantize_frame(png_bytes: bytes, colors: int) -> bytes:
|
|
|
303
318
|
result.save(buf, format="PNG", optimize=True)
|
|
304
319
|
return buf.getvalue()
|
|
305
320
|
|
|
306
|
-
import base64
|
|
307
|
-
import math
|
|
308
|
-
|
|
309
321
|
def build_drawing2(
|
|
310
322
|
frames: list[bytes],
|
|
311
323
|
fps: float,
|
|
312
|
-
|
|
324
|
+
width: int,
|
|
325
|
+
height: int,
|
|
313
326
|
play_mode: str = "loop"
|
|
314
327
|
) -> dict:
|
|
315
328
|
# Empirical scale formula
|
|
316
|
-
scale = max(1, round(
|
|
329
|
+
scale = max(1, round(max(width, height) / 20))
|
|
317
330
|
|
|
318
|
-
|
|
331
|
+
half_w, half_h = width / 2, height / 2
|
|
319
332
|
bounds = {
|
|
320
|
-
"minX": -
|
|
321
|
-
"maxX":
|
|
322
|
-
"minY": -
|
|
323
|
-
"maxY":
|
|
333
|
+
"minX": -half_w,
|
|
334
|
+
"maxX": half_w,
|
|
335
|
+
"minY": -half_h,
|
|
336
|
+
"maxY": half_h,
|
|
324
337
|
}
|
|
325
338
|
|
|
326
339
|
castle_frames = [{
|
|
@@ -368,8 +381,8 @@ def build_drawing2(
|
|
|
368
381
|
|
|
369
382
|
"physicsBodyData": {
|
|
370
383
|
"shapes": [{
|
|
371
|
-
"p1": {"x":
|
|
372
|
-
"p2": {"x": -
|
|
384
|
+
"p1": {"x": half_w / scale, "y": half_h / scale},
|
|
385
|
+
"p2": {"x": -half_w / scale, "y": -half_h / scale},
|
|
373
386
|
"p3": {"x": 0, "y": 0},
|
|
374
387
|
"radius": 0,
|
|
375
388
|
"x": 0,
|
|
@@ -384,6 +397,433 @@ def build_drawing2(
|
|
|
384
397
|
"disabled": False,
|
|
385
398
|
}
|
|
386
399
|
|
|
400
|
+
# ── svg → Drawing2 (vector) ──────────────────────────────────────────────────
|
|
401
|
+
# Merged from svg_to_castle.py: flattens all SVG paths to straight line
|
|
402
|
+
# segments (s:1, f:false). Bezier curves are sampled at `steps` intervals.
|
|
403
|
+
|
|
404
|
+
# Castle coordinate scale: SVG px → Castle units
|
|
405
|
+
# fillPixelsPerUnit = 25.6 by default
|
|
406
|
+
SVG_DEFAULT_PPU = 25.6
|
|
407
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _svg_lerp(a, b, t): return a + (b - a) * t
|
|
411
|
+
|
|
412
|
+
def _svg_quadratic_point(p0, p1, p2, t):
|
|
413
|
+
x = (1-t)**2*p0[0] + 2*(1-t)*t*p1[0] + t**2*p2[0]
|
|
414
|
+
y = (1-t)**2*p0[1] + 2*(1-t)*t*p1[1] + t**2*p2[1]
|
|
415
|
+
return (x, y)
|
|
416
|
+
|
|
417
|
+
def _svg_cubic_point(p0, p1, p2, p3, t):
|
|
418
|
+
x = (1-t)**3*p0[0] + 3*(1-t)**2*t*p1[0] + 3*(1-t)*t**2*p2[0] + t**3*p3[0]
|
|
419
|
+
y = (1-t)**3*p0[1] + 3*(1-t)**2*t*p1[1] + 3*(1-t)*t**2*p2[1] + t**3*p3[1]
|
|
420
|
+
return (x, y)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _svg_arc_to_lines(x1, y1, rx, ry, x_rot, large, sweep, x2, y2, steps):
|
|
424
|
+
"""Convert SVG arc to polyline points."""
|
|
425
|
+
if x1 == x2 and y1 == y2:
|
|
426
|
+
return []
|
|
427
|
+
if rx == 0 or ry == 0:
|
|
428
|
+
return [(x1, y1), (x2, y2)]
|
|
429
|
+
|
|
430
|
+
phi = math.radians(x_rot)
|
|
431
|
+
cos_phi, sin_phi = math.cos(phi), math.sin(phi)
|
|
432
|
+
|
|
433
|
+
dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
|
|
434
|
+
x1p = cos_phi * dx + sin_phi * dy
|
|
435
|
+
y1p = -sin_phi * dx + cos_phi * dy
|
|
436
|
+
|
|
437
|
+
rx, ry = abs(rx), abs(ry)
|
|
438
|
+
x1p2, y1p2, rx2, ry2 = x1p**2, y1p**2, rx**2, ry**2
|
|
439
|
+
lam = x1p2/rx2 + y1p2/ry2
|
|
440
|
+
if lam > 1:
|
|
441
|
+
sq = math.sqrt(lam)
|
|
442
|
+
rx, ry = sq * rx, sq * ry
|
|
443
|
+
rx2, ry2 = rx**2, ry**2
|
|
444
|
+
|
|
445
|
+
num = max(0, rx2*ry2 - rx2*y1p2 - ry2*x1p2)
|
|
446
|
+
den = rx2*y1p2 + ry2*x1p2
|
|
447
|
+
sq = math.sqrt(num / den) if den else 0
|
|
448
|
+
if large == sweep:
|
|
449
|
+
sq = -sq
|
|
450
|
+
|
|
451
|
+
cxp = sq * rx * y1p / ry
|
|
452
|
+
cyp = -sq * ry * x1p / rx
|
|
453
|
+
|
|
454
|
+
cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2
|
|
455
|
+
cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2
|
|
456
|
+
|
|
457
|
+
def angle(ux, uy, vx, vy):
|
|
458
|
+
n = math.sqrt(ux**2+uy**2) * math.sqrt(vx**2+vy**2)
|
|
459
|
+
if n == 0: return 0
|
|
460
|
+
c = max(-1, min(1, (ux*vx+uy*vy)/n))
|
|
461
|
+
a = math.acos(c)
|
|
462
|
+
if ux*vy - uy*vx < 0: a = -a
|
|
463
|
+
return a
|
|
464
|
+
|
|
465
|
+
theta1 = angle(1, 0, (x1p-cxp)/rx, (y1p-cyp)/ry)
|
|
466
|
+
dtheta = angle((x1p-cxp)/rx, (y1p-cyp)/ry, (-x1p-cxp)/rx, (-y1p-cyp)/ry)
|
|
467
|
+
|
|
468
|
+
if not sweep and dtheta > 0: dtheta -= 2*math.pi
|
|
469
|
+
if sweep and dtheta < 0: dtheta += 2*math.pi
|
|
470
|
+
|
|
471
|
+
pts = []
|
|
472
|
+
for i in range(steps + 1):
|
|
473
|
+
t = i / steps
|
|
474
|
+
theta = theta1 + t * dtheta
|
|
475
|
+
x = cos_phi*rx*math.cos(theta) - sin_phi*ry*math.sin(theta) + cx
|
|
476
|
+
y = sin_phi*rx*math.cos(theta) + cos_phi*ry*math.sin(theta) + cy
|
|
477
|
+
pts.append((x, y))
|
|
478
|
+
return pts
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _svg_parse_numbers(s: str) -> list[float]:
|
|
482
|
+
return [float(x) for x in re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', s)]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _svg_path_to_polylines(d: str, steps: int = 16) -> list[list[tuple]]:
|
|
486
|
+
"""
|
|
487
|
+
Parse SVG path 'd' attribute, return list of polylines.
|
|
488
|
+
Each polyline is a list of (x, y) tuples.
|
|
489
|
+
Beziers are sampled at 'steps' intervals.
|
|
490
|
+
"""
|
|
491
|
+
tokens = re.findall(r'[MmZzLlHhVvCcSsQqTtAa]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d)
|
|
492
|
+
cmd = None
|
|
493
|
+
pos = (0.0, 0.0)
|
|
494
|
+
start = (0.0, 0.0)
|
|
495
|
+
last_ctrl = None
|
|
496
|
+
polylines = []
|
|
497
|
+
current = []
|
|
498
|
+
|
|
499
|
+
def flush():
|
|
500
|
+
nonlocal current
|
|
501
|
+
if len(current) >= 2:
|
|
502
|
+
polylines.append(current)
|
|
503
|
+
current = []
|
|
504
|
+
|
|
505
|
+
i = 0
|
|
506
|
+
while i < len(tokens):
|
|
507
|
+
t = tokens[i]
|
|
508
|
+
if re.match(r'[MmZzLlHhVvCcSsQqTtAa]', t):
|
|
509
|
+
cmd = t
|
|
510
|
+
i += 1
|
|
511
|
+
if cmd in ('Z', 'z'):
|
|
512
|
+
if current and current[-1] != start:
|
|
513
|
+
current.append(start)
|
|
514
|
+
flush()
|
|
515
|
+
pos = start
|
|
516
|
+
cmd = None
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
if cmd in ('Z', 'z'):
|
|
520
|
+
if current and current[-1] != start:
|
|
521
|
+
current.append(start)
|
|
522
|
+
flush()
|
|
523
|
+
pos = start
|
|
524
|
+
cmd = None
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
nparams = {
|
|
528
|
+
'M':2,'m':2,'L':2,'l':2,'H':1,'h':1,'V':1,'v':1,
|
|
529
|
+
'C':6,'c':6,'S':4,'s':4,'Q':4,'q':4,'T':2,'t':2,
|
|
530
|
+
'A':7,'a':7,
|
|
531
|
+
}.get(cmd, 0)
|
|
532
|
+
|
|
533
|
+
if nparams == 0:
|
|
534
|
+
i += 1
|
|
535
|
+
continue
|
|
536
|
+
|
|
537
|
+
args = []
|
|
538
|
+
while len(args) < nparams and i < len(tokens):
|
|
539
|
+
tok = tokens[i]
|
|
540
|
+
if re.match(r'[MmZzLlHhVvCcSsQqTtAa]', tok):
|
|
541
|
+
break
|
|
542
|
+
args.append(float(tok))
|
|
543
|
+
i += 1
|
|
544
|
+
|
|
545
|
+
if len(args) < nparams:
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
rel = cmd.islower()
|
|
549
|
+
base = pos if rel else (0.0, 0.0)
|
|
550
|
+
|
|
551
|
+
if cmd in ('M', 'm'):
|
|
552
|
+
flush()
|
|
553
|
+
nx, ny = base[0]+args[0], base[1]+args[1]
|
|
554
|
+
pos = (nx, ny)
|
|
555
|
+
start = pos
|
|
556
|
+
current = [pos]
|
|
557
|
+
last_ctrl = None
|
|
558
|
+
cmd = 'l' if rel else 'L'
|
|
559
|
+
|
|
560
|
+
elif cmd in ('L', 'l'):
|
|
561
|
+
nx, ny = base[0]+args[0], base[1]+args[1]
|
|
562
|
+
pos = (nx, ny)
|
|
563
|
+
current.append(pos)
|
|
564
|
+
last_ctrl = None
|
|
565
|
+
|
|
566
|
+
elif cmd in ('H', 'h'):
|
|
567
|
+
nx = (pos[0] if rel else 0) + args[0]
|
|
568
|
+
pos = (nx, pos[1])
|
|
569
|
+
current.append(pos)
|
|
570
|
+
last_ctrl = None
|
|
571
|
+
|
|
572
|
+
elif cmd in ('V', 'v'):
|
|
573
|
+
ny = (pos[1] if rel else 0) + args[0]
|
|
574
|
+
pos = (pos[0], ny)
|
|
575
|
+
current.append(pos)
|
|
576
|
+
last_ctrl = None
|
|
577
|
+
|
|
578
|
+
elif cmd in ('C', 'c'):
|
|
579
|
+
p1 = (base[0]+args[0], base[1]+args[1])
|
|
580
|
+
p2 = (base[0]+args[2], base[1]+args[3])
|
|
581
|
+
p3 = (base[0]+args[4], base[1]+args[5])
|
|
582
|
+
for s in range(1, steps+1):
|
|
583
|
+
current.append(_svg_cubic_point(pos, p1, p2, p3, s/steps))
|
|
584
|
+
last_ctrl = p2
|
|
585
|
+
pos = p3
|
|
586
|
+
|
|
587
|
+
elif cmd in ('S', 's'):
|
|
588
|
+
p1 = (2*pos[0]-last_ctrl[0], 2*pos[1]-last_ctrl[1]) if last_ctrl else pos
|
|
589
|
+
p2 = (base[0]+args[0], base[1]+args[1])
|
|
590
|
+
p3 = (base[0]+args[2], base[1]+args[3])
|
|
591
|
+
for s in range(1, steps+1):
|
|
592
|
+
current.append(_svg_cubic_point(pos, p1, p2, p3, s/steps))
|
|
593
|
+
last_ctrl = p2
|
|
594
|
+
pos = p3
|
|
595
|
+
|
|
596
|
+
elif cmd in ('Q', 'q'):
|
|
597
|
+
p1 = (base[0]+args[0], base[1]+args[1])
|
|
598
|
+
p2 = (base[0]+args[2], base[1]+args[3])
|
|
599
|
+
for s in range(1, steps+1):
|
|
600
|
+
current.append(_svg_quadratic_point(pos, p1, p2, s/steps))
|
|
601
|
+
last_ctrl = p1
|
|
602
|
+
pos = p2
|
|
603
|
+
|
|
604
|
+
elif cmd in ('T', 't'):
|
|
605
|
+
p1 = (2*pos[0]-last_ctrl[0], 2*pos[1]-last_ctrl[1]) if last_ctrl else pos
|
|
606
|
+
p2 = (base[0]+args[0], base[1]+args[1])
|
|
607
|
+
for s in range(1, steps+1):
|
|
608
|
+
current.append(_svg_quadratic_point(pos, p1, p2, s/steps))
|
|
609
|
+
last_ctrl = p1
|
|
610
|
+
pos = p2
|
|
611
|
+
|
|
612
|
+
elif cmd in ('A', 'a'):
|
|
613
|
+
rx,ry,xr = args[0],args[1],args[2]
|
|
614
|
+
large,sweep = int(args[3]),int(args[4])
|
|
615
|
+
ex,ey = base[0]+args[5], base[1]+args[6]
|
|
616
|
+
pts = _svg_arc_to_lines(pos[0],pos[1],rx,ry,xr,large,sweep,ex,ey,steps)
|
|
617
|
+
if pts:
|
|
618
|
+
current.extend(pts[1:])
|
|
619
|
+
last_ctrl = None
|
|
620
|
+
pos = (ex, ey)
|
|
621
|
+
|
|
622
|
+
flush()
|
|
623
|
+
return polylines
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
SVG_COLORS = {
|
|
627
|
+
"black":(0,0,0),"white":(1,1,1),"red":(1,0,0),"green":(0,0.502,0),
|
|
628
|
+
"blue":(0,0,1),"yellow":(1,1,0),"cyan":(0,1,1),"magenta":(1,0,1),
|
|
629
|
+
"gray":(0.502,0.502,0.502),"grey":(0.502,0.502,0.502),
|
|
630
|
+
"none":None,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
def parse_svg_color(s: str, opacity: float = 1.0):
|
|
634
|
+
if not s or s == "none": return None
|
|
635
|
+
s = s.strip().lower()
|
|
636
|
+
if s in SVG_COLORS:
|
|
637
|
+
c = SVG_COLORS[s]
|
|
638
|
+
return [*c, opacity] if c else None
|
|
639
|
+
if s.startswith("#"):
|
|
640
|
+
h = s[1:]
|
|
641
|
+
if len(h) == 3: h = h[0]*2+h[1]*2+h[2]*2
|
|
642
|
+
r,g,b = int(h[0:2],16)/255, int(h[2:4],16)/255, int(h[4:6],16)/255
|
|
643
|
+
return [r, g, b, opacity]
|
|
644
|
+
m = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', s)
|
|
645
|
+
if m:
|
|
646
|
+
return [int(m.group(i))/255 for i in (1,2,3)] + [opacity]
|
|
647
|
+
return [0, 0, 0, opacity]
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def svg_to_path_data(svg_path, steps: int = 16, scale: float = 1.0,
|
|
651
|
+
ppu: float = SVG_DEFAULT_PPU, color=None):
|
|
652
|
+
"""
|
|
653
|
+
Parse an SVG file and return a Castle pathDataList and framesBounds.
|
|
654
|
+
scale: multiplier applied to Castle units (1.0 = default)
|
|
655
|
+
ppu: pixels per unit (default 25.6)
|
|
656
|
+
color: override color [r,g,b,a], None = use SVG colors
|
|
657
|
+
"""
|
|
658
|
+
tree = ET.parse(svg_path)
|
|
659
|
+
root = tree.getroot()
|
|
660
|
+
|
|
661
|
+
def tag(el): return el.tag.split('}')[-1] if '}' in el.tag else el.tag
|
|
662
|
+
|
|
663
|
+
vb = root.get("viewBox")
|
|
664
|
+
if vb:
|
|
665
|
+
parts = _svg_parse_numbers(vb)
|
|
666
|
+
vb_x, vb_y, vb_w, vb_h = parts
|
|
667
|
+
else:
|
|
668
|
+
vb_x, vb_y = 0, 0
|
|
669
|
+
vb_w = float(root.get("width", 100))
|
|
670
|
+
vb_h = float(root.get("height", 100))
|
|
671
|
+
|
|
672
|
+
# SVG Y is flipped vs Castle Y
|
|
673
|
+
def to_castle_raw(x, y):
|
|
674
|
+
return (x / ppu * scale, -(y / ppu) * scale)
|
|
675
|
+
|
|
676
|
+
_cx_offset = [0.0]
|
|
677
|
+
_cy_offset = [0.0]
|
|
678
|
+
|
|
679
|
+
def to_castle(x, y):
|
|
680
|
+
rx, ry = to_castle_raw(x, y)
|
|
681
|
+
return (rx - _cx_offset[0], ry - _cy_offset[0])
|
|
682
|
+
|
|
683
|
+
path_data = []
|
|
684
|
+
default_color = color or [0, 0, 0, 1]
|
|
685
|
+
all_xs, all_ys = [], []
|
|
686
|
+
|
|
687
|
+
def get_color(el):
|
|
688
|
+
if color: return color
|
|
689
|
+
stroke = el.get("stroke") or el.get("style", "")
|
|
690
|
+
m = re.search(r'stroke\s*:\s*([^;]+)', stroke)
|
|
691
|
+
stroke_val = m.group(1).strip() if m else (el.get("stroke") or "black")
|
|
692
|
+
op_m = re.search(r'stroke-opacity\s*:\s*([^;]+)', el.get("style",""))
|
|
693
|
+
opacity = float(op_m.group(1)) if op_m else float(el.get("stroke-opacity", 1))
|
|
694
|
+
c = parse_svg_color(stroke_val, opacity)
|
|
695
|
+
return c if c else default_color
|
|
696
|
+
|
|
697
|
+
def process_element(el):
|
|
698
|
+
t = tag(el)
|
|
699
|
+
c = get_color(el)
|
|
700
|
+
|
|
701
|
+
polylines = []
|
|
702
|
+
|
|
703
|
+
if t == "path":
|
|
704
|
+
d = el.get("d", "")
|
|
705
|
+
polylines = _svg_path_to_polylines(d, steps)
|
|
706
|
+
|
|
707
|
+
elif t == "line":
|
|
708
|
+
x1,y1 = float(el.get("x1",0)), float(el.get("y1",0))
|
|
709
|
+
x2,y2 = float(el.get("x2",0)), float(el.get("y2",0))
|
|
710
|
+
polylines = [[(x1,y1),(x2,y2)]]
|
|
711
|
+
|
|
712
|
+
elif t == "polyline" or t == "polygon":
|
|
713
|
+
pts_raw = _svg_parse_numbers(el.get("points",""))
|
|
714
|
+
pts = [(pts_raw[i],pts_raw[i+1]) for i in range(0,len(pts_raw)-1,2)]
|
|
715
|
+
if t == "polygon" and pts: pts.append(pts[0])
|
|
716
|
+
polylines = [pts]
|
|
717
|
+
|
|
718
|
+
elif t == "rect":
|
|
719
|
+
x,y = float(el.get("x",0)), float(el.get("y",0))
|
|
720
|
+
w,h = float(el.get("width",0)), float(el.get("height",0))
|
|
721
|
+
polylines = [[(x,y),(x+w,y),(x+w,y+h),(x,y+h),(x,y)]]
|
|
722
|
+
|
|
723
|
+
elif t == "circle":
|
|
724
|
+
cx2,cy2,r = float(el.get("cx",0)),float(el.get("cy",0)),float(el.get("r",1))
|
|
725
|
+
pts = [(cx2+r*math.cos(2*math.pi*i/steps),
|
|
726
|
+
cy2+r*math.sin(2*math.pi*i/steps)) for i in range(steps+1)]
|
|
727
|
+
polylines = [pts]
|
|
728
|
+
|
|
729
|
+
elif t == "ellipse":
|
|
730
|
+
cx2,cy2 = float(el.get("cx",0)),float(el.get("cy",0))
|
|
731
|
+
rx2,ry2 = float(el.get("rx",1)),float(el.get("ry",1))
|
|
732
|
+
pts = [(cx2+rx2*math.cos(2*math.pi*i/steps),
|
|
733
|
+
cy2+ry2*math.sin(2*math.pi*i/steps)) for i in range(steps+1)]
|
|
734
|
+
polylines = [pts]
|
|
735
|
+
|
|
736
|
+
for poly in polylines:
|
|
737
|
+
if len(poly) < 2: continue
|
|
738
|
+
castle_pts = [to_castle(x, y) for x, y in poly]
|
|
739
|
+
for j in range(len(castle_pts)-1):
|
|
740
|
+
x1,y1 = castle_pts[j]
|
|
741
|
+
x2,y2 = castle_pts[j+1]
|
|
742
|
+
path_data.append({
|
|
743
|
+
"p": [round(x1,5), round(y1,5), round(x2,5), round(y2,5)],
|
|
744
|
+
"s": 1,
|
|
745
|
+
"f": False,
|
|
746
|
+
"c": [round(v,5) for v in c],
|
|
747
|
+
})
|
|
748
|
+
all_xs.extend([x1, x2])
|
|
749
|
+
all_ys.extend([y1, y2])
|
|
750
|
+
|
|
751
|
+
for child in el:
|
|
752
|
+
process_element(child)
|
|
753
|
+
|
|
754
|
+
process_element(root)
|
|
755
|
+
|
|
756
|
+
if not all_xs:
|
|
757
|
+
return path_data, {"minX":-5,"maxX":5,"minY":-5,"maxY":5}, {"minX":-32,"maxX":32,"minY":-32,"maxY":32}
|
|
758
|
+
|
|
759
|
+
mid_x = (min(all_xs) + max(all_xs)) / 2
|
|
760
|
+
mid_y = (min(all_ys) + max(all_ys)) / 2
|
|
761
|
+
for seg in path_data:
|
|
762
|
+
seg["p"][0] = round(seg["p"][0] - mid_x, 5)
|
|
763
|
+
seg["p"][1] = round(seg["p"][1] - mid_y, 5)
|
|
764
|
+
seg["p"][2] = round(seg["p"][2] - mid_x, 5)
|
|
765
|
+
seg["p"][3] = round(seg["p"][3] - mid_y, 5)
|
|
766
|
+
all_xs = [x - mid_x for x in all_xs]
|
|
767
|
+
all_ys = [y - mid_y for y in all_ys]
|
|
768
|
+
|
|
769
|
+
bounds = {
|
|
770
|
+
"minX": round(min(all_xs), 5),
|
|
771
|
+
"maxX": round(max(all_xs), 5),
|
|
772
|
+
"minY": round(min(all_ys), 5),
|
|
773
|
+
"maxY": round(max(all_ys), 5),
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
fill_bounds = {
|
|
777
|
+
"minX": round(min(all_xs) * ppu / scale),
|
|
778
|
+
"maxX": round(max(all_xs) * ppu / scale),
|
|
779
|
+
"minY": round(-max(all_ys) * ppu / scale),
|
|
780
|
+
"maxY": round(-min(all_ys) * ppu / scale),
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return path_data, bounds, fill_bounds
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def build_drawing2_vector(path_data, bounds, fill_bounds,
|
|
787
|
+
scale=10, ppu=SVG_DEFAULT_PPU) -> dict:
|
|
788
|
+
frame = {
|
|
789
|
+
"isLinked": False,
|
|
790
|
+
"pathDataList": path_data,
|
|
791
|
+
"fillImageBounds": fill_bounds,
|
|
792
|
+
"avatarX": 0, "avatarY": 0, "avatarRadius": 5,
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
"initialFrame": 1, "currentFrame": 1,
|
|
796
|
+
"framesPerSecond": 4,
|
|
797
|
+
"playMode": "still",
|
|
798
|
+
"loopStartFrame": -1, "loopEndFrame": -1,
|
|
799
|
+
"opacity": 1,
|
|
800
|
+
"hash": str(abs(hash(str(path_data))))[:19],
|
|
801
|
+
"playing": False, "loop": False,
|
|
802
|
+
"drawData": {
|
|
803
|
+
"color": [1,1,1,1], "lineColor": [0,0,0,1],
|
|
804
|
+
"gridSize": 0.71428, "scale": scale, "version": 3,
|
|
805
|
+
"fillPixelsPerUnit": ppu,
|
|
806
|
+
"numTotalLayers": 1,
|
|
807
|
+
"framesBounds": [bounds],
|
|
808
|
+
"colors": [], "selectedFrame": 1,
|
|
809
|
+
"layers": [{
|
|
810
|
+
"title": "Layer 1", "id": "layer1",
|
|
811
|
+
"isVisible": True, "isBitmap": False, "isAvatar": False,
|
|
812
|
+
"frames": [frame],
|
|
813
|
+
}],
|
|
814
|
+
},
|
|
815
|
+
"physicsBodyData": {
|
|
816
|
+
"shapes": [{
|
|
817
|
+
"p1": {"x": 5, "y": 5}, "p2": {"x": -5, "y": -5},
|
|
818
|
+
"p3": {"x": 0, "y": 0}, "radius": 0, "x": 0, "y": 0,
|
|
819
|
+
"type": "rectangle",
|
|
820
|
+
}],
|
|
821
|
+
"scale": scale, "version": 2, "zeroShapesInV1": False,
|
|
822
|
+
},
|
|
823
|
+
"disabled": False,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
387
827
|
# ── midi → Music ─────────────────────────────────────────────────────────────
|
|
388
828
|
|
|
389
829
|
def beat_key(b): return f"{b:.6f}"
|
|
@@ -467,7 +907,7 @@ def collect_midi_tracks(mid) -> list[list[tuple[float,int]]]:
|
|
|
467
907
|
return result
|
|
468
908
|
|
|
469
909
|
def build_music(midi_path: Path, beats_per_bar: int = 4) -> dict:
|
|
470
|
-
mid = mido.MidiFile(midi_path)
|
|
910
|
+
mid = mido.MidiFile(midi_path, clip=True)
|
|
471
911
|
midi_tracks = collect_midi_tracks(mid)
|
|
472
912
|
patterns, castle_tracks = {}, []
|
|
473
913
|
for t_idx, events in enumerate(midi_tracks):
|
|
@@ -512,46 +952,27 @@ def build_music(midi_path: Path, beats_per_bar: int = 4) -> dict:
|
|
|
512
952
|
|
|
513
953
|
def main():
|
|
514
954
|
p()
|
|
515
|
-
pb("
|
|
516
|
-
pb("
|
|
517
|
-
pb("
|
|
955
|
+
pb("═══════════════")
|
|
956
|
+
pb(" Castletool ")
|
|
957
|
+
pb("═══════════════")
|
|
518
958
|
p()
|
|
519
959
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if not HAS_MIDO: missing.append("mido (pip install mido)")
|
|
524
|
-
if not HAS_PT: missing.append("prompt_toolkit (pip install prompt_toolkit) — enables tab-complete for file paths")
|
|
525
|
-
if not HAS_FZF:
|
|
526
|
-
print(f" {DIM}tip: install fzf for fuzzy file finding (pkg install fzf){RESET}")
|
|
527
|
-
if not HAS_FFMPEG:
|
|
528
|
-
print(f" {DIM}tip: install ffmpeg for MP4/video support (pkg install ffmpeg){RESET}")
|
|
529
|
-
if missing:
|
|
530
|
-
pw("Missing optional dependencies (only needed for relevant features):")
|
|
531
|
-
for m in missing: print(f" • {m}")
|
|
532
|
-
p()
|
|
533
|
-
|
|
534
|
-
# ── android warning ──
|
|
535
|
-
termux = is_termux()
|
|
536
|
-
if termux:
|
|
537
|
-
pw("Android/Termux detected. Use ~/storage/... paths for files.")
|
|
538
|
-
if not check_storage_setup():
|
|
539
|
-
setup_termux_storage()
|
|
540
|
-
p()
|
|
960
|
+
check_for_update()
|
|
961
|
+
ensure_dependencies()
|
|
962
|
+
p()
|
|
541
963
|
|
|
542
964
|
# ── select deck ──
|
|
543
965
|
home = Path.cwd()
|
|
544
966
|
decks = find_decks(home)
|
|
545
967
|
if not decks:
|
|
546
|
-
pe("No
|
|
547
|
-
pe("Run 'castle get-deck <id> <folder>' first.")
|
|
968
|
+
pe("No decks were found. Are you in the right directory?")
|
|
548
969
|
sys.exit(1)
|
|
549
970
|
|
|
550
971
|
deck_names = [d.name for d in decks]
|
|
551
972
|
pb(f"Detected decks: {', '.join(deck_names)}")
|
|
552
973
|
if len(decks) == 1:
|
|
553
974
|
deck = decks[0]
|
|
554
|
-
ps(f"
|
|
975
|
+
ps(f"Auto selecting {deck.name}")
|
|
555
976
|
else:
|
|
556
977
|
chosen = choose("Select the deck you would like to modify:", deck_names)
|
|
557
978
|
deck = home / chosen
|
|
@@ -565,7 +986,7 @@ def main():
|
|
|
565
986
|
card_names = [c.name for c in cards]
|
|
566
987
|
if len(cards) == 1:
|
|
567
988
|
card = cards[0]
|
|
568
|
-
ps(f"
|
|
989
|
+
ps(f"Auto selecting {card.name}")
|
|
569
990
|
else:
|
|
570
991
|
chosen = choose("Select the card you would like to edit:", card_names)
|
|
571
992
|
card = deck / "cards" / chosen
|
|
@@ -579,7 +1000,7 @@ def main():
|
|
|
579
1000
|
bp_names = [b.name for b in blueprints]
|
|
580
1001
|
if len(blueprints) == 1:
|
|
581
1002
|
bp_path = blueprints[0]
|
|
582
|
-
ps(f"
|
|
1003
|
+
ps(f"Auto selecting {bp_path.name}")
|
|
583
1004
|
else:
|
|
584
1005
|
chosen = choose("Select the blueprint you would like to edit:", bp_names)
|
|
585
1006
|
bp_path = card / "scene" / "blueprints" / chosen
|
|
@@ -606,41 +1027,35 @@ def main():
|
|
|
606
1027
|
pe(f"File not found: {img_path}")
|
|
607
1028
|
|
|
608
1029
|
ext = img_path.suffix.lower()
|
|
609
|
-
is_anim = ext in (".gif", ".mp4", ".mov", ".webm", ".avi")
|
|
1030
|
+
is_anim = ext in (".gif", ".webp", ".mp4", ".mov", ".webm", ".avi")
|
|
610
1031
|
is_video = ext in (".mp4", ".mov", ".webm", ".avi")
|
|
611
1032
|
is_vector = ext in (".svg",)
|
|
612
1033
|
file_size = img_path.stat().st_size
|
|
613
1034
|
|
|
1035
|
+
# native size + scale
|
|
614
1036
|
p()
|
|
1037
|
+
width, every, quantize = None, 1, 0
|
|
615
1038
|
if not is_vector:
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
# size
|
|
622
|
-
p()
|
|
623
|
-
resize = (not is_vector) and yn("Would you like to resize this image?", default="n")
|
|
624
|
-
size, every, quantize = None, 1, 0
|
|
625
|
-
if not is_vector:
|
|
626
|
-
if resize:
|
|
627
|
-
while True:
|
|
628
|
-
raw = ask("Enter size (e.g. 64x64 or just 64)", default="64x64")
|
|
629
|
-
raw = raw.strip().lower().replace("x", " ").split()
|
|
630
|
-
try:
|
|
631
|
-
size = int(raw[0])
|
|
632
|
-
break
|
|
633
|
-
except:
|
|
634
|
-
pe("Invalid size.")
|
|
1039
|
+
if is_video:
|
|
1040
|
+
if not HAS_FFMPEG:
|
|
1041
|
+
pe("ffmpeg is required for video files. Install with: pkg install ffmpeg")
|
|
1042
|
+
sys.exit(1)
|
|
1043
|
+
native_w, native_h = probe_video_dimensions(img_path)
|
|
635
1044
|
else:
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1045
|
+
with Image.open(img_path) as _img:
|
|
1046
|
+
native_w, native_h = _img.size
|
|
1047
|
+
|
|
1048
|
+
raw = ask("Scale (1 = full resolution, 0.5 = half, 2 = double)", default="1")
|
|
1049
|
+
try:
|
|
1050
|
+
img_scale = float(raw)
|
|
1051
|
+
if img_scale <= 0:
|
|
1052
|
+
raise ValueError
|
|
1053
|
+
except ValueError:
|
|
1054
|
+
pw("Invalid scale, using 1.")
|
|
1055
|
+
img_scale = 1.0
|
|
1056
|
+
|
|
1057
|
+
width = max(1, round(native_w * img_scale))
|
|
1058
|
+
height = max(1, round(native_h * img_scale))
|
|
644
1059
|
|
|
645
1060
|
# frame skip for animations
|
|
646
1061
|
if is_anim:
|
|
@@ -657,7 +1072,7 @@ def main():
|
|
|
657
1072
|
p()
|
|
658
1073
|
if file_size > 500_000:
|
|
659
1074
|
pw(f"This file is abnormally large ({file_size//1024}KB). Quantizing is recommended.")
|
|
660
|
-
if yn("Would you like to quantize this image? (reduces file size
|
|
1075
|
+
if yn("Would you like to quantize this image? (reduces file size)", default="y" if file_size > 500_000 else "n"):
|
|
661
1076
|
while True:
|
|
662
1077
|
raw = ask("Select number of colors", default="256")
|
|
663
1078
|
if raw.isdigit() and 1 <= int(raw) <= 256:
|
|
@@ -667,9 +1082,6 @@ def main():
|
|
|
667
1082
|
|
|
668
1083
|
p()
|
|
669
1084
|
if is_vector:
|
|
670
|
-
if not HAS_SVG:
|
|
671
|
-
pe("svg_to_castle.py not found — place it in the same folder as castle_tool.py")
|
|
672
|
-
sys.exit(1)
|
|
673
1085
|
pb(f"Loading SVG: {img_path.name}")
|
|
674
1086
|
svg_scale = 1.0
|
|
675
1087
|
if yn("Would you like to scale the SVG output?", default="n"):
|
|
@@ -698,9 +1110,9 @@ def main():
|
|
|
698
1110
|
if not HAS_FFMPEG:
|
|
699
1111
|
pe("ffmpeg is required for video files. Install with: pkg install ffmpeg")
|
|
700
1112
|
sys.exit(1)
|
|
701
|
-
frames, fps = extract_mp4_frames(img_path,
|
|
1113
|
+
frames, fps = extract_mp4_frames(img_path, width, height, every_n=every)
|
|
702
1114
|
else:
|
|
703
|
-
frames, fps = load_image_frames(img_path,
|
|
1115
|
+
frames, fps = load_image_frames(img_path, width, height)
|
|
704
1116
|
if every > 1:
|
|
705
1117
|
frames = frames[::every]
|
|
706
1118
|
fps = fps / every
|
|
@@ -710,8 +1122,8 @@ def main():
|
|
|
710
1122
|
frames = [quantize_frame(f, quantize) for f in frames]
|
|
711
1123
|
|
|
712
1124
|
play_mode = "loop" if is_anim else "still"
|
|
713
|
-
drawing2 = build_drawing2(frames, fps,
|
|
714
|
-
ps(f"Image ready: {len(frames)} frame(s), {fps} FPS, {
|
|
1125
|
+
drawing2 = build_drawing2(frames, fps, width, height, play_mode)
|
|
1126
|
+
ps(f"Image ready: {len(frames)} frame(s), {fps} FPS, {width}×{height}px")
|
|
715
1127
|
|
|
716
1128
|
# ── midi? ──
|
|
717
1129
|
music = None
|
|
@@ -746,16 +1158,8 @@ def main():
|
|
|
746
1158
|
pw("Nothing to do. Exiting.")
|
|
747
1159
|
sys.exit(0)
|
|
748
1160
|
|
|
749
|
-
# ── confirm overwrite ──
|
|
750
|
-
p()
|
|
751
|
-
what = []
|
|
752
|
-
if drawing2: what.append("image/animation (Drawing2)")
|
|
753
|
-
if music: what.append("music (Music)")
|
|
754
|
-
pw(f"This will overwrite {' and '.join(what)} in:\n {bp_path}")
|
|
755
|
-
if not yn("Are you sure you want to modify this blueprint?"):
|
|
756
|
-
sys.exit(0)
|
|
757
|
-
|
|
758
1161
|
# ── inject ──
|
|
1162
|
+
p()
|
|
759
1163
|
if drawing2:
|
|
760
1164
|
actor["actorBlueprint"]["components"]["Drawing2"] = drawing2
|
|
761
1165
|
if music:
|
|
@@ -763,17 +1167,19 @@ def main():
|
|
|
763
1167
|
|
|
764
1168
|
with open(bp_path, "w", encoding="utf-8") as f:
|
|
765
1169
|
json.dump(actor, f, indent=2)
|
|
766
|
-
ps(f"Blueprint written to {bp_path}")
|
|
767
1170
|
|
|
768
1171
|
# ── save ──
|
|
769
1172
|
p()
|
|
770
1173
|
if yn("Would you like to save this deck now?"):
|
|
771
1174
|
result = subprocess.run(["castle", "save-deck", str(deck)],
|
|
772
1175
|
capture_output=True, text=True)
|
|
1176
|
+
combined = (result.stdout + result.stderr).lower()
|
|
773
1177
|
if result.returncode == 0:
|
|
774
1178
|
ps("Saved!")
|
|
775
1179
|
for line in result.stdout.strip().splitlines():
|
|
776
1180
|
print(f" {line}")
|
|
1181
|
+
elif "memory access out of bounds" in combined:
|
|
1182
|
+
pe("Your file is too large!")
|
|
777
1183
|
else:
|
|
778
1184
|
pe("Save failed:")
|
|
779
1185
|
print(result.stdout)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: castletool
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "castletool"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Your Name", email = "your.email@example.com" }
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|