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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: castletool
3
- Version: 0.2.2
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
- ans = ask(prompt + " (y/n)", default=default).lower()
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
- if HAS_FZF:
123
- start = search_dir or Path.cwd()
124
- ps(f"Opening fzf in {start} — type to fuzzy search, Enter to select, Esc to type manually.")
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
- def check_storage_setup() -> bool:
161
- return (Path.home() / "storage").exists()
112
+ # ── update check ─────────────────────────────────────────────────────────────
162
113
 
163
- def setup_termux_storage():
164
- p()
165
- pw("It looks like you do not have storage set up.")
166
- if yn("Would you like to set it up now?"):
167
- pb("Running 'termux-setup-storage'...")
168
- subprocess.run(["termux-setup-storage"])
169
- ps("Setup complete! You may need to restart the script.")
170
- sys.exit(0)
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, size: int):
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((size, size), resample)
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((size, size), resample)
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 probe_video_size(path: Path) -> int:
225
- """Return max(width, height) of a video file via ffprobe."""
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 max(int(w), int(h))
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, size: int, every_n: int = 1) -> tuple[list[bytes], float]:
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
- src_size = probe_video_size(path)
263
- scale_flags = "neighbor" if size > src_size else "bicubic"
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={size}:{size}:force_original_aspect_ratio=decrease:flags={scale_flags},pad={size}:{size}:(ow-iw)/2:(oh-ih)/2",
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
- size: int,
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(size / 20))
329
+ scale = max(1, round(max(width, height) / 20))
317
330
 
318
- half = size / 2
331
+ half_w, half_h = width / 2, height / 2
319
332
  bounds = {
320
- "minX": -half,
321
- "maxX": half,
322
- "minY": -half,
323
- "maxY": half,
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": half / scale, "y": half / scale},
372
- "p2": {"x": -half / scale, "y": -half / scale},
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(" Castle Blueprint Tool ")
517
- pb("═══════════════════════════════════")
955
+ pb("═══════════════")
956
+ pb(" Castletool ")
957
+ pb("═══════════════")
518
958
  p()
519
959
 
520
- # ── check deps ──
521
- missing = []
522
- if not HAS_PIL: missing.append("Pillow (pip install Pillow)")
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 Castle decks found in the current directory.")
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"Only one deck ({deck.name}), auto-selecting.")
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"Only one card ({card.name}), auto-selecting.")
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"Only one blueprint ({bp_path.name}), auto-selecting.")
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
- pw("Make sure this file is formatted correctly. You can verify by importing it in the Castle app.")
617
- pw("If it's corrupt and you ignore this, the card may become corrupted.")
618
- if not yn("Continue?"):
619
- sys.exit(0)
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
- if is_video:
637
- if not HAS_FFMPEG:
638
- pe("ffmpeg is required for video files. Install with: pkg install ffmpeg")
639
- sys.exit(1)
640
- size = probe_video_size(img_path)
641
- else:
642
- with Image.open(img_path) as _img:
643
- size = max(_img.size)
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, usually no visible difference at ≥256 colors)", default="y" if file_size > 500_000 else "n"):
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, size, every_n=every)
1113
+ frames, fps = extract_mp4_frames(img_path, width, height, every_n=every)
702
1114
  else:
703
- frames, fps = load_image_frames(img_path, size)
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, size)
714
- ps(f"Image ready: {len(frames)} frame(s), {fps} FPS, {size}×{size}px")
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.2.2
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.2.2"
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