castletool 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .castletool import *
@@ -0,0 +1,647 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ castle_tool.py — Interactive Castle deck editor
4
+ Streamlines injecting images/GIFs and MIDI into Castle blueprint JSON files.
5
+ """
6
+
7
+ import base64
8
+ import copy
9
+ import io
10
+ import json
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import uuid
17
+ from pathlib import Path
18
+
19
+ # ── optional deps ────────────────────────────────────────────────────────────
20
+ try:
21
+ from PIL import Image
22
+ HAS_PIL = True
23
+ except ImportError:
24
+ HAS_PIL = False
25
+
26
+ try:
27
+ import mido
28
+ HAS_MIDO = True
29
+ except ImportError:
30
+ HAS_MIDO = False
31
+
32
+ try:
33
+ from prompt_toolkit import prompt as pt_prompt
34
+ from prompt_toolkit.completion import PathCompleter
35
+ from prompt_toolkit.styles import Style
36
+ HAS_PT = True
37
+ except ImportError:
38
+ HAS_PT = False
39
+
40
+ HAS_FZF = shutil.which("fzf") is not None
41
+
42
+ try:
43
+ from svg_to_castle import svg_to_path_data, build_drawing2_vector
44
+ HAS_SVG = True
45
+ except ImportError:
46
+ HAS_SVG = False
47
+ HAS_FFMPEG = shutil.which("ffmpeg") is not None
48
+
49
+ # ── helpers ──────────────────────────────────────────────────────────────────
50
+
51
+ BOLD = "\033[1m"
52
+ DIM = "\033[2m"
53
+ RESET = "\033[0m"
54
+ WARN = "\033[33m"
55
+ OK = "\033[32m"
56
+ ERR = "\033[31m"
57
+
58
+ def p(msg=""): print(msg)
59
+ def pb(msg): print(f"{BOLD}{msg}{RESET}")
60
+ def pw(msg): print(f"{WARN}⚠ {msg}{RESET}")
61
+ def pe(msg): print(f"{ERR}✗ {msg}{RESET}")
62
+ def ps(msg): print(f"{OK}✓ {msg}{RESET}")
63
+
64
+ def ask(prompt, default=None):
65
+ suffix = f" [{default}]" if default is not None else ""
66
+ try:
67
+ val = input(f"{BOLD}{prompt}{suffix}{RESET} ").strip()
68
+ except (EOFError, KeyboardInterrupt):
69
+ p(); sys.exit(0)
70
+ return val if val else (default or "")
71
+
72
+ def yn(prompt, default="y"):
73
+ while True:
74
+ ans = ask(prompt + " (y/n)", default=default).lower()
75
+ if ans in ("y", "n"):
76
+ return ans == "y"
77
+ p("Please enter y or n.")
78
+
79
+ def choose(prompt, options):
80
+ """Pick from a numbered list. Returns chosen item."""
81
+ p()
82
+ pb(prompt)
83
+ for i, o in enumerate(options, 1):
84
+ print(f" {i}) {o}")
85
+ while True:
86
+ raw = ask(f"Enter number (1-{len(options)})")
87
+ if raw.isdigit() and 1 <= int(raw) <= len(options):
88
+ return options[int(raw) - 1]
89
+ p("Invalid choice.")
90
+
91
+ def fzf_pick_file(start_dir: Path) -> str | None:
92
+ """Open fzf to fuzzy-find a file under start_dir. Returns path string or None."""
93
+ try:
94
+ # find all files under start_dir, pipe into fzf
95
+ find = subprocess.run(
96
+ ["find", str(start_dir), "-type", "f"],
97
+ capture_output=True, text=True
98
+ )
99
+ fzf = subprocess.run(
100
+ ["fzf", "--prompt", "Select file> ", "--height", "40%",
101
+ "--layout", "reverse", "--border"],
102
+ input=find.stdout,
103
+ capture_output=True, text=True
104
+ )
105
+ result = fzf.stdout.strip()
106
+ return result if result else None
107
+ except Exception:
108
+ return None
109
+
110
+
111
+ def ask_path(prompt, default=None, search_dir: Path | None = None):
112
+ """
113
+ File path input. Priority:
114
+ 1. fzf fuzzy picker (if fzf installed)
115
+ 2. prompt_toolkit tab-complete (if installed)
116
+ 3. plain input fallback
117
+ """
118
+ suffix = f" [{default}]" if default is not None else ""
119
+ pb(prompt + suffix)
120
+
121
+ if HAS_FZF:
122
+ start = search_dir or Path.home()
123
+ ps(f"Opening fzf in {start} — type to fuzzy search, Enter to select, Esc to type manually.")
124
+ picked = fzf_pick_file(start)
125
+ if picked:
126
+ ps(f"Selected: {picked}")
127
+ return picked
128
+ pw("fzf cancelled, falling back to manual input.")
129
+
130
+ if HAS_PT:
131
+ try:
132
+ from prompt_toolkit.formatted_text import FormattedText
133
+ val = pt_prompt(
134
+ FormattedText([("bold", "Path: ")]),
135
+ completer=PathCompleter(expanduser=True),
136
+ complete_while_typing=False,
137
+ ).strip()
138
+ except (EOFError, KeyboardInterrupt):
139
+ p(); sys.exit(0)
140
+ else:
141
+ try:
142
+ val = input(f"{BOLD}Path: {RESET}").strip()
143
+ except (EOFError, KeyboardInterrupt):
144
+ p(); sys.exit(0)
145
+ return val if val else (default or "")
146
+
147
+
148
+ def resolve_path(raw: str) -> Path:
149
+ raw = raw.strip().strip("'\"")
150
+ if raw.startswith("~/"):
151
+ raw = str(Path.home()) + raw[1:]
152
+ return Path(raw).expanduser().resolve()
153
+
154
+ # ── android / termux detection ───────────────────────────────────────────────
155
+
156
+ def is_termux() -> bool:
157
+ return "com.termux" in str(Path.home()) or os.environ.get("TERMUX_VERSION") is not None
158
+
159
+ def check_storage_setup() -> bool:
160
+ return (Path.home() / "storage").exists()
161
+
162
+ def setup_termux_storage():
163
+ p()
164
+ pw("It looks like you do not have storage set up.")
165
+ if yn("Would you like to set it up now?"):
166
+ pb("Running 'termux-setup-storage'...")
167
+ subprocess.run(["termux-setup-storage"])
168
+ ps("Setup complete! You may need to restart the script.")
169
+ sys.exit(0)
170
+
171
+ # ── deck / card / blueprint discovery ────────────────────────────────────────
172
+
173
+ def find_decks(search_dir: Path) -> list[Path]:
174
+ """Return folders that look like Castle decks (contain deck.json)."""
175
+ return sorted([d for d in search_dir.iterdir()
176
+ if d.is_dir() and (d / "deck.json").exists()])
177
+
178
+ def find_cards(deck: Path) -> list[Path]:
179
+ cards_dir = deck / "cards"
180
+ if not cards_dir.exists():
181
+ return []
182
+ return sorted([c for c in cards_dir.iterdir() if c.is_dir()])
183
+
184
+ def find_blueprints(card: Path) -> list[Path]:
185
+ bp_dir = card / "scene" / "blueprints"
186
+ if not bp_dir.exists():
187
+ return []
188
+ return sorted([f for f in bp_dir.glob("*.json")])
189
+
190
+ # ── image/gif → Drawing2 ─────────────────────────────────────────────────────
191
+
192
+ def load_image_frames(path: Path, size: int) -> tuple[list[bytes], float]:
193
+ img = Image.open(path)
194
+ frames, durations = [], []
195
+ try:
196
+ while True:
197
+ frame = img.convert("RGBA").resize((size, size), Image.LANCZOS)
198
+ buf = io.BytesIO()
199
+ frame.save(buf, format="PNG", optimize=False)
200
+ frames.append(buf.getvalue())
201
+ durations.append(img.info.get("duration", 100))
202
+ img.seek(img.tell() + 1)
203
+ except EOFError:
204
+ pass
205
+ avg_ms = sum(durations) / len(durations) if durations else 100
206
+ return frames, round(1000 / avg_ms, 2)
207
+
208
+ def extract_mp4_frames(path: Path, size: int, every_n: int = 1) -> tuple[list[bytes], float]:
209
+ """Extract frames from MP4 using ffmpeg. Returns (png_frames, fps)."""
210
+ if not HAS_FFMPEG:
211
+ pe("ffmpeg not found. Install with: pkg install ffmpeg")
212
+ sys.exit(1)
213
+
214
+ # Get fps via ffprobe
215
+ probe = subprocess.run(
216
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
217
+ "-show_entries", "stream=r_frame_rate",
218
+ "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
219
+ capture_output=True, text=True
220
+ )
221
+ raw_fps = probe.stdout.strip()
222
+ try:
223
+ num, den = raw_fps.split("/")
224
+ fps = float(num) / float(den)
225
+ except Exception:
226
+ fps = 30.0
227
+
228
+ effective_fps = fps / every_n
229
+
230
+ # Extract frames as PNG via pipe
231
+ result = subprocess.run(
232
+ ["ffmpeg", "-i", str(path),
233
+ "-vf", f"select='not(mod(n\\,{every_n}))',scale={size}:{size}:force_original_aspect_ratio=decrease,pad={size}:{size}:(ow-iw)/2:(oh-ih)/2",
234
+ "-vsync", "vfr",
235
+ "-f", "image2pipe", "-vcodec", "png", "-"],
236
+ capture_output=True
237
+ )
238
+
239
+ # Split raw stdout into individual PNGs by PNG magic bytes
240
+ raw = result.stdout
241
+ png_magic = bytes([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])
242
+ frames = []
243
+ start = 0
244
+ while True:
245
+ idx = raw.find(png_magic, start)
246
+ if idx == -1:
247
+ break
248
+ next_idx = raw.find(png_magic, idx + 1)
249
+ chunk = raw[idx:next_idx] if next_idx != -1 else raw[idx:]
250
+ frames.append(chunk)
251
+ start = next_idx if next_idx != -1 else len(raw)
252
+
253
+ if not frames:
254
+ pe("ffmpeg returned no frames. Is the file a valid MP4?")
255
+ sys.exit(1)
256
+
257
+ return frames, effective_fps
258
+
259
+
260
+ def quantize_frame(png_bytes: bytes, colors: int) -> bytes:
261
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
262
+ rgb = img.convert("RGB").quantize(colors=colors,
263
+ method=Image.Quantize.MEDIANCUT).convert("RGB")
264
+ result = Image.new("RGBA", img.size)
265
+ result.paste(rgb)
266
+ result.putalpha(img.split()[3])
267
+ buf = io.BytesIO()
268
+ result.save(buf, format="PNG", optimize=True)
269
+ return buf.getvalue()
270
+
271
+ def build_drawing2(frames: list[bytes], fps: float, size: int,
272
+ play_mode: str = "loop", scale: int = 10) -> dict:
273
+ half = size // 2
274
+ bounds = {"minX": -half, "maxX": half, "minY": -half, "maxY": half}
275
+ castle_frames = [{
276
+ "isLinked": False,
277
+ "pathDataList": [],
278
+ "fillImageBounds": bounds,
279
+ "fillPng": base64.b64encode(p).decode(),
280
+ "avatarX": 0, "avatarY": 0, "avatarRadius": 5,
281
+ } for p in frames]
282
+ return {
283
+ "initialFrame": 1, "currentFrame": 1,
284
+ "framesPerSecond": fps,
285
+ "playMode": play_mode,
286
+ "loopStartFrame": -1, "loopEndFrame": -1,
287
+ "opacity": 1,
288
+ "hash": str(abs(hash(frames[0])))[:19],
289
+ "playing": False,
290
+ "loop": play_mode != "still",
291
+ "drawData": {
292
+ "color": [1,1,1,1], "lineColor": [0,0,0,1],
293
+ "gridSize": 0.71428, "scale": scale, "version": 3,
294
+ "fillPixelsPerUnit": scale,
295
+ "numTotalLayers": 1,
296
+ "framesBounds": [bounds] * len(frames),
297
+ "colors": [], "selectedFrame": 1,
298
+ "layers": [{
299
+ "title": "Layer 1", "id": "layer1",
300
+ "isVisible": True, "isBitmap": True, "isAvatar": False,
301
+ "frames": castle_frames,
302
+ }],
303
+ },
304
+ "physicsBodyData": {
305
+ "shapes": [{
306
+ "p1": {"x": half/scale, "y": half/scale},
307
+ "p2": {"x": -half/scale, "y": -half/scale},
308
+ "p3": {"x": 0, "y": 0},
309
+ "radius": 0, "x": 0, "y": 0, "type": "rectangle",
310
+ }],
311
+ "scale": scale, "version": 2, "zeroShapesInV1": False,
312
+ },
313
+ "disabled": False,
314
+ }
315
+
316
+ # ── midi → Music ─────────────────────────────────────────────────────────────
317
+
318
+ def beat_key(b): return f"{b:.6f}"
319
+ def make_color(): return {"r": 0.19607, "g": 0.16862, "b": 0.15681, "a": 1.0}
320
+
321
+ def collect_midi_tracks(mid) -> list[list[tuple[float,int]]]:
322
+ tpb = mid.ticks_per_beat
323
+ result = []
324
+ for track in mid.tracks:
325
+ events, abs_tick = [], 0
326
+ for msg in track:
327
+ abs_tick += msg.time
328
+ if msg.type == "note_on" and msg.velocity > 0:
329
+ events.append((abs_tick / tpb, msg.note))
330
+ if events:
331
+ result.append(events)
332
+ return result
333
+
334
+ def events_to_bars(events, beats_per_bar):
335
+ bars = {}
336
+ for beat, note in events:
337
+ idx = int(beat // beats_per_bar)
338
+ rel = round(beat - idx * beats_per_bar, 9)
339
+ bars.setdefault(idx, {}).setdefault(rel, []).append(note)
340
+ return [(idx * beats_per_bar, notes) for idx, notes in sorted(bars.items())]
341
+
342
+ def build_music(midi_path: Path, beats_per_bar: int = 4) -> dict:
343
+ mid = mido.MidiFile(midi_path)
344
+ midi_tracks = collect_midi_tracks(mid)
345
+ patterns, castle_tracks = {}, []
346
+ for t_idx, events in enumerate(midi_tracks):
347
+ seq = {}
348
+ for bar_beat, notes_by_beat in events_to_bars(events, beats_per_bar):
349
+ pid = str(uuid.uuid4())
350
+ notes = {beat_key(b): [{"key": n} for n in ns]
351
+ for b, ns in sorted(notes_by_beat.items())}
352
+ patterns[pid] = {
353
+ "patternId": pid,
354
+ "name": f"t{t_idx}-b{int(bar_beat//beats_per_bar)+1}",
355
+ "color": make_color(), "loop": "nextBar", "loopLength": 0,
356
+ "notes": notes,
357
+ }
358
+ seq[bar_beat] = pid
359
+ castle_tracks.append({
360
+ "instrument": {
361
+ "type": "sampler",
362
+ "props": {"name": "tone", "muted": False, "volume": 1},
363
+ "sample": {
364
+ "type": "tone",
365
+ "playbackRate": {"value": 1}, "amplitude": {"value": 1},
366
+ "pan": {"value": 0}, "recordingUrl": "", "uploadUrl": "",
367
+ "category": "random", "seed": 1337, "mutationSeed": 0,
368
+ "mutationAmount": 5, "midiNote": 48, "waveform": "sawtooth",
369
+ "attack": 0, "release": 0.4, "wait": False,
370
+ },
371
+ },
372
+ "sequence": {
373
+ beat_key(b): {"patternId": pid, "loop": False}
374
+ for b, pid in sorted(seq.items())
375
+ },
376
+ })
377
+ return {
378
+ "song": {"patterns": patterns, "tracks": castle_tracks},
379
+ "autoplay": "loop", "disabled": False,
380
+ }
381
+
382
+ # ── main flow ─────────────────────────────────────────────────────────────────
383
+
384
+ def main():
385
+ p()
386
+ pb("═══════════════════════════════════")
387
+ pb(" Castle Blueprint Tool ")
388
+ pb("═══════════════════════════════════")
389
+ p()
390
+
391
+ # ── check deps ──
392
+ missing = []
393
+ if not HAS_PIL: missing.append("Pillow (pip install Pillow)")
394
+ if not HAS_MIDO: missing.append("mido (pip install mido)")
395
+ if not HAS_PT: missing.append("prompt_toolkit (pip install prompt_toolkit) — enables tab-complete for file paths")
396
+ if not HAS_FZF:
397
+ print(f" {DIM}tip: install fzf for fuzzy file finding (pkg install fzf){RESET}")
398
+ if not HAS_FFMPEG:
399
+ print(f" {DIM}tip: install ffmpeg for MP4/video support (pkg install ffmpeg){RESET}")
400
+ if missing:
401
+ pw("Missing optional dependencies (only needed for relevant features):")
402
+ for m in missing: print(f" • {m}")
403
+ p()
404
+
405
+ # ── android warning ──
406
+ termux = is_termux()
407
+ if termux:
408
+ pw("Android/Termux detected. Use ~/storage/... paths for files.")
409
+ if not check_storage_setup():
410
+ setup_termux_storage()
411
+ p()
412
+
413
+ # ── select deck ──
414
+ home = Path.home()
415
+ decks = find_decks(home)
416
+ if not decks:
417
+ pe("No Castle decks found in home directory.")
418
+ pe("Run 'castle get-deck <id> <folder>' first.")
419
+ sys.exit(1)
420
+
421
+ deck_names = [d.name for d in decks]
422
+ pb(f"Detected decks: {', '.join(deck_names)}")
423
+ if len(decks) == 1:
424
+ deck = decks[0]
425
+ ps(f"Only one deck ({deck.name}), auto-selecting.")
426
+ else:
427
+ chosen = choose("Select the deck you would like to modify:", deck_names)
428
+ deck = home / chosen
429
+ p()
430
+
431
+ # ── select card ──
432
+ cards = find_cards(deck)
433
+ if not cards:
434
+ pe(f"No cards found in {deck}.")
435
+ sys.exit(1)
436
+ card_names = [c.name for c in cards]
437
+ if len(cards) == 1:
438
+ card = cards[0]
439
+ ps(f"Only one card ({card.name}), auto-selecting.")
440
+ else:
441
+ chosen = choose("Select the card you would like to edit:", card_names)
442
+ card = deck / "cards" / chosen
443
+ p()
444
+
445
+ # ── select blueprint ──
446
+ blueprints = find_blueprints(card)
447
+ if not blueprints:
448
+ pe(f"No blueprints found in {card}.")
449
+ sys.exit(1)
450
+ bp_names = [b.name for b in blueprints]
451
+ if len(blueprints) == 1:
452
+ bp_path = blueprints[0]
453
+ ps(f"Only one blueprint ({bp_path.name}), auto-selecting.")
454
+ else:
455
+ chosen = choose("Select the blueprint you would like to edit:", bp_names)
456
+ bp_path = card / "scene" / "blueprints" / chosen
457
+ p()
458
+
459
+ # ── load blueprint ──
460
+ with open(bp_path, "r", encoding="utf-8") as f:
461
+ actor = json.load(f)
462
+
463
+ # ── image/gif? ──
464
+ drawing2 = None
465
+ if not HAS_PIL:
466
+ pw("Pillow not installed, skipping image/GIF option.")
467
+ do_image = False
468
+ else:
469
+ do_image = yn("Would you like to add an image or animation?")
470
+
471
+ if do_image:
472
+ while True:
473
+ raw = ask_path("Enter the file path for your image")
474
+ img_path = resolve_path(raw)
475
+ if img_path.exists():
476
+ break
477
+ pe(f"File not found: {img_path}")
478
+
479
+ ext = img_path.suffix.lower()
480
+ is_anim = ext in (".gif", ".mp4", ".mov", ".webm", ".avi")
481
+ is_video = ext in (".mp4", ".mov", ".webm", ".avi")
482
+ is_vector = ext in (".svg",)
483
+ file_size = img_path.stat().st_size
484
+
485
+ p()
486
+ if not is_vector:
487
+ pw("Make sure this file is formatted correctly. You can verify by importing it in the Castle app.")
488
+ pw("If it's corrupt and you ignore this, the card may become corrupted.")
489
+ if not yn("Continue?"):
490
+ sys.exit(0)
491
+
492
+ # size
493
+ p()
494
+ resize = (not is_vector) and yn("Would you like to resize this image?", default="n")
495
+ size, every, quantize = 64, 1, 0
496
+ if not is_vector:
497
+ if resize:
498
+ while True:
499
+ raw = ask("Enter size (e.g. 64x64 or just 64)", default="64x64")
500
+ raw = raw.strip().lower().replace("x", " ").split()
501
+ try:
502
+ size = int(raw[0])
503
+ break
504
+ except:
505
+ pe("Invalid size.")
506
+
507
+ # frame skip for animations
508
+ if is_anim:
509
+ p()
510
+ if yn("Would you like to skip frames? (reduces file size for long GIFs)", default="n"):
511
+ while True:
512
+ raw = ask("Keep every Nth frame (e.g. 2 = half frames, 4 = quarter)", default="2")
513
+ if raw.isdigit() and int(raw) >= 1:
514
+ every = int(raw)
515
+ break
516
+ pe("Enter a positive integer.")
517
+
518
+ # quantize
519
+ p()
520
+ if file_size > 500_000 or yn("Would you like to quantize this image? (reduces file size, usually no visible difference at ≥256 colors)", default="n"):
521
+ if file_size > 500_000:
522
+ pw(f"This file is abnormally large ({file_size//1024}KB). Quantizing is recommended.")
523
+ while True:
524
+ raw = ask("Select number of colors", default="256")
525
+ if raw.isdigit() and 1 <= int(raw) <= 256:
526
+ quantize = int(raw)
527
+ break
528
+ pe("Enter a number between 1 and 256.")
529
+
530
+ p()
531
+ if is_vector:
532
+ if not HAS_SVG:
533
+ pe("svg_to_castle.py not found — place it in the same folder as castle_tool.py")
534
+ sys.exit(1)
535
+ pb(f"Loading SVG: {img_path.name}")
536
+ svg_scale = 1.0
537
+ if yn("Would you like to scale the SVG output?", default="n"):
538
+ while True:
539
+ raw = ask("Enter scale multiplier (e.g. 2.0 = twice as large)", default="1.0")
540
+ try:
541
+ svg_scale = float(raw)
542
+ break
543
+ except ValueError:
544
+ pe("Enter a number like 1.0 or 0.5")
545
+ steps = 16
546
+ if yn("Customize bezier curve smoothness? (default 16 steps)", default="n"):
547
+ while True:
548
+ raw = ask("Steps per curve segment", default="16")
549
+ if raw.isdigit() and int(raw) >= 2:
550
+ steps = int(raw)
551
+ break
552
+ pe("Enter a number ≥ 2")
553
+ path_data, bounds, fill_bounds = svg_to_path_data(
554
+ img_path, steps=steps, scale=svg_scale)
555
+ drawing2 = build_drawing2_vector(path_data, bounds, fill_bounds)
556
+ ps(f"SVG ready: {len(path_data)} line segments")
557
+ else:
558
+ pb(f"Loading {'video' if is_video else 'image'}: {img_path.name}")
559
+ if is_video:
560
+ if not HAS_FFMPEG:
561
+ pe("ffmpeg is required for video files. Install with: pkg install ffmpeg")
562
+ sys.exit(1)
563
+ frames, fps = extract_mp4_frames(img_path, size, every_n=every)
564
+ else:
565
+ frames, fps = load_image_frames(img_path, size)
566
+ if every > 1:
567
+ frames = frames[::every]
568
+ fps = fps / every
569
+
570
+ if quantize:
571
+ pb(f"Quantizing {len(frames)} frame(s) to {quantize} colors...")
572
+ frames = [quantize_frame(f, quantize) for f in frames]
573
+
574
+ play_mode = "loop" if is_anim else "still"
575
+ drawing2 = build_drawing2(frames, fps, size)
576
+ ps(f"Image ready: {len(frames)} frame(s), {fps} FPS, {size}×{size}px")
577
+
578
+ # ── midi? ──
579
+ music = None
580
+ if not HAS_MIDO:
581
+ pw("mido not installed, skipping MIDI option.")
582
+ do_midi = False
583
+ else:
584
+ p()
585
+ do_midi = yn("Would you like to add a MIDI file?")
586
+
587
+ if do_midi:
588
+ while True:
589
+ raw = ask_path("Enter your MIDI file path")
590
+ midi_path = resolve_path(raw)
591
+ if midi_path.exists():
592
+ break
593
+ pe(f"File not found: {midi_path}")
594
+
595
+ p()
596
+ pb(f"Loading MIDI: {midi_path.name}")
597
+ mid = mido.MidiFile(midi_path)
598
+ midi_track_count = sum(
599
+ 1 for t in mid.tracks
600
+ if any(m.type == "note_on" and m.velocity > 0 for m in t)
601
+ )
602
+ music = build_music(midi_path)
603
+ pat_count = len(music["song"]["patterns"])
604
+ trk_count = len(music["song"]["tracks"])
605
+ ps(f"MIDI ready: {pat_count} patterns, {trk_count} tracks")
606
+
607
+ if drawing2 is None and music is None:
608
+ pw("Nothing to do. Exiting.")
609
+ sys.exit(0)
610
+
611
+ # ── confirm overwrite ──
612
+ p()
613
+ what = []
614
+ if drawing2: what.append("image/animation (Drawing2)")
615
+ if music: what.append("music (Music)")
616
+ pw(f"This will overwrite {' and '.join(what)} in:\n {bp_path}")
617
+ if not yn("Are you sure you want to modify this blueprint?"):
618
+ sys.exit(0)
619
+
620
+ # ── inject ──
621
+ if drawing2:
622
+ actor["actorBlueprint"]["components"]["Drawing2"] = drawing2
623
+ if music:
624
+ actor["actorBlueprint"]["components"]["Music"] = music
625
+
626
+ with open(bp_path, "w", encoding="utf-8") as f:
627
+ json.dump(actor, f, indent=2)
628
+ ps(f"Blueprint written to {bp_path}")
629
+
630
+ # ── save ──
631
+ p()
632
+ if yn("Would you like to save this deck now?"):
633
+ result = subprocess.run(["castle", "save-deck", str(deck)],
634
+ capture_output=True, text=True)
635
+ if result.returncode == 0:
636
+ ps("Saved!")
637
+ for line in result.stdout.strip().splitlines():
638
+ print(f" {line}")
639
+ else:
640
+ pe("Save failed:")
641
+ print(result.stdout)
642
+ print(result.stderr)
643
+ p()
644
+
645
+
646
+ if __name__ == "__main__":
647
+ main()
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: castletool
3
+ Version: 0.1.0
4
+ Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
5
+ Author-email: Your Name <your.email@example.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Environment :: Console
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Provides-Extra: all
13
+ Requires-Dist: Pillow>=9.0.0; extra == "all"
14
+ Requires-Dist: mido>=1.2.10; extra == "all"
15
+ Requires-Dist: prompt_toolkit>=3.0.0; extra == "all"
@@ -0,0 +1,7 @@
1
+ castletool/__init__.py,sha256=IgaClVx13-If4-r_tjhWsa4ywTic0LtwXQQjHJ0JcHw,25
2
+ castletool/castletool.py,sha256=O3KrpRUh-CwqZYIptHcMxvA09m1CeQb0pLruGqnwp5U,23938
3
+ castletool-0.1.0.dist-info/METADATA,sha256=86hQ1CTPsc6Z3q4shmsu_8jqS0cftYfK7bV_I7RWdxc,619
4
+ castletool-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ castletool-0.1.0.dist-info/entry_points.txt,sha256=G7B6o2ADEuOBRLjyWe3jATYg2zkKPvXeJreUHpK4Cpw,47
6
+ castletool-0.1.0.dist-info/top_level.txt,sha256=Tmt_LeVKavS0M7uFx0iTRDcNs5Pw_MJneMoOKiZQoxc,11
7
+ castletool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ castletool = castletool:main
@@ -0,0 +1 @@
1
+ castletool