| """Generate fal SAM 3D Object GLBs for Tiny Toybox source images. |
| |
| This uses fal's installed Python package and sends source images as data URLs, |
| which avoids needing `fal files upload` permissions. |
| |
| Run from the project root: |
| |
| /Library/Frameworks/Python.framework/Versions/3.14/bin/python3 scripts/generate_sam_3d_models.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import base64 |
| import json |
| import mimetypes |
| import shutil |
| import subprocess |
| import urllib.request |
| from pathlib import Path |
|
|
| import fal.apps |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| SOURCE_DIR = ROOT / "potential-char-images" |
| RAW_DIR = SOURCE_DIR / "extracted-from-sam" |
|
|
|
|
| CHARACTERS = { |
| "squeaky": { |
| "image": SOURCE_DIR / "squeaky.png", |
| "prompt": "plushie squeaky elephant with bowler hat, books backpack, pocket clock, cute chibi toy character", |
| }, |
| "electraica": { |
| "image": SOURCE_DIR / "electraica-(her).png", |
| "prompt": "plushie Electraica electric helper character, bulb on head, battery pack, nut and bolt hands, cute chibi toy character", |
| }, |
| "fire-boy": { |
| "image": SOURCE_DIR / "fire-boy.png", |
| "prompt": "plushie Fire Boy character, flame hood, tuxedo, fire extinguisher backpack, flute, cute chibi toy character", |
| }, |
| "shark-girl": { |
| "image": SOURCE_DIR / "shark-girl.png", |
| "prompt": "plushie Shark Girl character, shark hood, cream butler tie, guitar, cute chibi toy character", |
| }, |
| } |
|
|
|
|
| def data_url(path: Path) -> str: |
| mime = mimetypes.guess_type(path.name)[0] or "image/png" |
| return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii") |
|
|
|
|
| def download(url: str, path: Path) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| try: |
| with urllib.request.urlopen(url) as response: |
| path.write_bytes(response.read()) |
| except Exception: |
| subprocess.run(["curl", "-fL", url, "-o", str(path)], check=True) |
|
|
|
|
| def download_from_metadata(metadata: Path, output: Path) -> bool: |
| if not metadata.exists() or output.exists(): |
| return output.exists() |
| result = json.loads(metadata.read_text(encoding="utf-8")) |
| url = (result.get("model_glb") or {}).get("url") |
| if not url: |
| return False |
| print(f"Recovering download from {metadata}") |
| download(url, output) |
| return True |
|
|
|
|
| def generate(slug: str, force: bool) -> Path: |
| info = CHARACTERS[slug] |
| image = info["image"] |
| output = RAW_DIR / f"{slug}-sam.glb" |
| metadata = RAW_DIR / f"{slug}-sam-result.json" |
| if download_from_metadata(metadata, output) and not force: |
| print(f"Skipping {slug}; recovered/exists at {output}") |
| return output |
| if output.exists() and not force: |
| print(f"Skipping {slug}; already exists at {output}") |
| return output |
| if not image.exists(): |
| raise FileNotFoundError(image) |
|
|
| params = { |
| "image_url": data_url(image), |
| "prompt": info["prompt"], |
| "export_textured_glb": True, |
| } |
| print(f"Submitting {slug} to fal-ai/sam-3/3d-objects") |
| handle = fal.apps.submit("fal-ai/sam-3/3d-objects", params) |
| print(f"{slug}: request_id={handle.request_id}") |
| for event in handle.iter_events(logs=True): |
| print(f"{slug}: {type(event).__name__}") |
| result = handle.fetch_result() |
| metadata.write_text(json.dumps(result, indent=2), encoding="utf-8") |
|
|
| glb = result.get("model_glb") or {} |
| url = glb.get("url") |
| if not url: |
| raise RuntimeError(f"{slug}: fal result did not include model_glb.url") |
| download(url, output) |
| print(f"Saved {slug}: {output}") |
| return output |
|
|
|
|
| def seed_existing_fire_boy() -> None: |
| existing = RAW_DIR / "combined_scene (2).glb" |
| normalized = RAW_DIR / "fire-boy-sam.glb" |
| if existing.exists() and not normalized.exists(): |
| shutil.copy2(existing, normalized) |
| print(f"Seeded fire-boy from existing SAM export: {normalized}") |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--force", action="store_true", help="Regenerate even if the named GLB already exists.") |
| parser.add_argument("slugs", nargs="*", choices=sorted(CHARACTERS), help="Specific characters to generate.") |
| args = parser.parse_args() |
|
|
| RAW_DIR.mkdir(parents=True, exist_ok=True) |
| seed_existing_fire_boy() |
|
|
| slugs = args.slugs or list(CHARACTERS) |
| for slug in slugs: |
| generate(slug, args.force) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|