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 *
|
castletool/castletool.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
castletool
|