Implement storage architecture from ai_blueprint.md

Primary sync: replace PersistenceActor JSON file with SwiftData + CloudKit
- Add SavedCardModel (@Model class) and PersistenceController (ModelContainer
  with .automatic CloudKit, fallback to local). BackgroundPersistenceActor
  (@ModelActor) handles all DB I/O off the main thread.
- One-time migration imports user_collection.json into SwiftData and renames
  the original file to prevent re-import.
- Inject modelContainer into SwiftUI environment in IYmtgApp.

Image storage: Documents/UserContent/ subfolder (blueprint requirement)
- ImageManager.dir now targets iCloud Documents/UserContent/ (or local equiv).
- migrateImagesToUserContent() moves existing JPGs to the new subfolder on
  first launch; called during the SwiftData migration.

Firebase: demoted to optional manual backup (metadata only, no images)
- Remove all automatic CloudEngine.save/delete/batchUpdatePrices calls from
  CollectionViewModel mutations.
- Add backupAllToFirebase() for user-triggered metadata sync.
- Add isFirebaseBackupEnabled to AppConfig (default false).
- Add Cloud Backup section in Library settings with iCloud vs Firebase
  explanation and "Backup Metadata to Firebase Now" button.

Also: full modular refactor (Data/, Features/, Services/ directories) and
README updated with CloudKit setup steps and revised release checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:13:17 -05:00
parent b993ef4020
commit 24dcb44af4
38 changed files with 2786 additions and 2105 deletions

View File

@@ -1,18 +1,31 @@
# INSTALL: pip install requests pillow
#
# SETUP: Replace the email below with your real contact email.
# Scryfall requires an accurate User-Agent per their API policy.
# See: https://scryfall.com/docs/api
CONTACT_EMAIL = "support@iymtg.com" # <-- UPDATE THIS
import os, requests, time, concurrent.futures
from PIL import Image
from io import BytesIO
HEADERS = { 'User-Agent': 'IYmtg/1.0 (contact@yourdomain.com)', 'Accept': 'application/json' }
HEADERS = { 'User-Agent': f'IYmtg/1.0 ({CONTACT_EMAIL})', 'Accept': 'application/json' }
OUTPUT_DIR = "Set_Symbol_Training"
# Layouts where the set symbol is NOT at the standard M15 position.
# Excluding these prevents corrupt/misaligned crops in the training set.
EXCLUDED_LAYOUTS = {'transform', 'modal_dfc', 'reversible_card', 'planeswalker', 'saga', 'battle', 'split', 'flip'}
def main():
if CONTACT_EMAIL == "support@iymtg.com":
print("⚠️ WARNING: Using default contact email in User-Agent. Update CONTACT_EMAIL at the top of this script.")
if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR)
print("--- IYmtg Symbol Harvester ---")
session = requests.Session()
session.headers.update(HEADERS)
try:
response = session.get("https://api.scryfall.com/sets", timeout=15)
response.raise_for_status()
@@ -20,13 +33,14 @@ def main():
except Exception as e:
print(f"Error fetching sets: {e}")
return
valid_types = ['core', 'expansion', 'masters', 'draft_innovation']
target_sets = [s for s in all_sets if s.get('set_type') in valid_types]
print(f"Found {len(target_sets)} valid sets.")
# OPTIMIZATION: Process sets in parallel to speed up dataset creation
# OPTIMIZATION: Process sets in parallel to speed up dataset creation.
# max_workers=5 keeps concurrent requests well within Scryfall's 10 req/s limit.
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
executor.map(lambda s: process_set(s, session), target_sets)
@@ -35,57 +49,75 @@ def main():
def process_set(set_obj, session):
set_code = set_obj['code']
print(f"Processing {set_code}...")
set_dir = os.path.join(OUTPUT_DIR, set_code.upper())
os.makedirs(set_dir, exist_ok=True)
try:
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5"
# Filter to standard single-faced cards to guarantee reliable symbol crop coordinates.
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints+layout:normal&per_page=5"
resp = session.get(url, timeout=10)
if resp.status_code == 404:
# No normal-layout cards found; fall back to any card type
url = f"https://api.scryfall.com/cards/search?q=set:{set_code}+unique:prints&per_page=5"
resp = session.get(url, timeout=10)
if resp.status_code != 200:
print(f"Skipping {set_code}: HTTP {resp.status_code}")
return
cards_resp = resp.json()
saved = 0
for i, card in enumerate(cards_resp.get('data', [])):
# Skip layouts where the symbol position differs from the standard M15 crop area
if card.get('layout', '') in EXCLUDED_LAYOUTS:
continue
image_url = None
uris = {}
if 'image_uris' in card:
uris = card['image_uris']
elif 'card_faces' in card and len(card['card_faces']) > 0 and 'image_uris' in card['card_faces'][0]:
uris = card['card_faces'][0]['image_uris']
if 'large' in uris: image_url = uris['large']
elif 'normal' in uris: image_url = uris['normal']
if image_url:
try:
# Brief sleep between image downloads to respect Scryfall rate limits
time.sleep(0.05)
img_resp = session.get(image_url, timeout=10)
if img_resp.status_code != 200: continue
try:
img = Image.open(BytesIO(img_resp.content))
img.verify() # Verify integrity
img = Image.open(BytesIO(img_resp.content)) # Re-open after verify
img.verify() # Verify integrity
img = Image.open(BytesIO(img_resp.content)) # Re-open after verify
except Exception:
print(f"Skipping corrupt image in {set_code}")
continue
width, height = img.size
# WARNING: This crop area is tuned for modern card frames (M15+).
# Older sets or special frames (Planeswalkers, Sagas) may require different coordinates.
# WARNING: This crop area is tuned for standard M15+ single-faced cards.
# Excluded layouts (DFC, Planeswalker, Saga, etc.) are filtered above.
crop_area = (width * 0.85, height * 0.58, width * 0.95, height * 0.65)
symbol = img.crop(crop_area)
symbol = symbol.convert("RGB")
symbol.save(os.path.join(set_dir, f"sample_{i}.jpg"))
saved += 1
except Exception as e:
print(f"Error downloading image for {set_code}: {e}")
if saved == 0:
print(f"⚠️ No usable images saved for {set_code}")
except Exception as e:
print(f"Error searching cards for {set_code}: {e}")
if __name__ == "__main__": main()
if __name__ == "__main__": main()

View File

@@ -1,5 +1,4 @@
import os
import sys
import os, sys, argparse
# Dependency Check
try:
@@ -17,17 +16,34 @@ OUTPUT_DIR = os.path.join(BASE_DIR, "Ready_Assets")
# Target Dimensions (Width, Height)
ASSETS = {
"AppIcon": (1024, 1024),
"logo_header": (300, 80),
"scanner_frame": (600, 800),
"empty_library": (800, 800),
"share_watermark": (400, 100),
"card_placeholder": (600, 840)
"AppIcon": (1024, 1024),
"logo_header": (300, 80),
"scanner_frame": (600, 800),
"empty_library": (800, 800),
"share_watermark": (400, 100),
"card_placeholder": (600, 840),
}
def process_assets():
# Assets whose target aspect ratio differs significantly from a typical AI square output.
# These use thumbnail+pad (letterbox) instead of center-crop to preserve full image content.
PAD_ASSETS = {"logo_header", "share_watermark"}
def resize_fit(img, size):
"""Center-crop and scale to exact size. Best when source matches target aspect ratio."""
return ImageOps.fit(img, size, method=Image.Resampling.LANCZOS)
def resize_pad(img, size, bg_color=(0, 0, 0, 0)):
"""Scale to fit within size, then pad with bg_color to reach exact dimensions.
Preserves the full image — nothing is cropped. Ideal for logos and banners."""
img.thumbnail(size, Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", size, bg_color)
offset = ((size[0] - img.width) // 2, (size[1] - img.height) // 2)
canvas.paste(img, offset)
return canvas
def process_assets(force_pad=False, force_crop=False):
print(f"📂 Working Directory: {BASE_DIR}")
# Create directories if they don't exist
if not os.path.exists(SOURCE_DIR):
os.makedirs(SOURCE_DIR)
@@ -51,19 +67,32 @@ def process_assets():
img = Image.open(source_path)
if img.mode != 'RGBA': img = img.convert('RGBA')
# Smart Crop & Resize (Center focus)
processed_img = ImageOps.fit(img, size, method=Image.Resampling.LANCZOS)
# Choose resize strategy:
# - PAD_ASSETS (and --pad flag) use thumbnail+letterbox to avoid cropping banner images.
# - Everything else uses center-crop (ImageOps.fit) for a clean full-bleed fill.
use_pad = (name in PAD_ASSETS or force_pad) and not force_crop
if use_pad:
processed_img = resize_pad(img, size)
mode_label = "pad"
else:
processed_img = resize_fit(img, size)
mode_label = "crop"
output_path = os.path.join(OUTPUT_DIR, name + ".png")
processed_img.save(output_path, "PNG")
print(f"✅ Generated: {name}.png ({size[0]}x{size[1]})")
print(f"✅ Generated: {name}.png ({size[0]}x{size[1]}, {mode_label})")
found = True
break
except Exception as e:
print(f"❌ Error processing {name}: {e}")
if not found:
print(f"⚠️ Skipped: {name} (File not found in Raw_Assets)")
if __name__ == "__main__":
process_assets()
parser = argparse.ArgumentParser(description="Resize raw AI assets to Xcode-ready dimensions.")
group = parser.add_mutually_exclusive_group()
group.add_argument("--pad", action="store_true", help="Force thumbnail+pad for ALL assets (no cropping).")
group.add_argument("--crop", action="store_true", help="Force center-crop for ALL assets (overrides default pad assets).")
args = parser.parse_args()
process_assets(force_pad=args.pad, force_crop=args.crop)