""" ThreeGen — text -> JSON scene graph (tiny model) -> Three.js -> live preview + copy code. Run locally: MOCK=1 python app.py (no model download, instant UI) Run with model: python app.py (downloads MODEL_ID on first call) On HF Spaces: set the SDK to gradio; ZeroGPU handles the @gpu decorator. """ from __future__ import annotations import json import logging import os import re import gradio as gr from compiler import compile_html, iframe from llm import MAX_PROMPT_CHARS, mock_scene_json, run_llm from scene import TEMPLATES, build_scene, extract_json logging.basicConfig( level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s", ) log = logging.getLogger(__name__) # ---- ZeroGPU decorator (no-op when `spaces` isn't installed / running local) ---- try: import spaces def gpu(fn): return spaces.GPU(duration=120)(fn) except Exception: def gpu(fn): return fn @gpu def _generate_json(prompt: str) -> str: return run_llm(prompt) EXAMPLES = [ "a star badge with the text PRO", "a shield badge with the text NEW", "a row of three gold coins side by side", "a tall trophy: a star on top of a golden column", "a molten-gold torus knot, high metalness, low roughness", "a faceted crystal — one large icosahedron, electric blue, metallic", "three glowing neon-green cubes stacked into a tower", "a red wireframe sphere inside a larger blue wireframe sphere", ] _NESTED_SPHERE_PATTERNS = ("wireframe sphere inside", "sphere inside", "nested sphere") _BADGE_TEXT_RE = re.compile( r'\b(star|shield|hexagon|badge|heart)\b.{0,60}' r'\b(?:text|saying|word|label)\b\s*["\']?([A-Za-z0-9]{1,20})["\']?', re.IGNORECASE, ) def _detect_badge_template(prompt: str): m = _BADGE_TEXT_RE.search(prompt) if m: return m.group(1).lower(), m.group(2).upper() return None, None # ---- Generation ---- def generate(prompt: str, glow: bool, glow_strength: float, style: str, motion: str, lighting: str, shadows: bool): prompt = (prompt or "").strip() if not prompt: return gr.update(), gr.update(), gr.update(), "Type a prompt first.", None if len(prompt) > MAX_PROMPT_CHARS: return gr.update(), gr.update(), gr.update(), f"Prompt too long — keep it under {MAX_PROMPT_CHARS} chars.", None lo = prompt.lower() _glow = glow_strength / 10.0 # slider is 0-20; compiler expects 0.0-2.0 badge_shape, badge_text = _detect_badge_template(lo) if badge_shape and badge_text: scene = build_scene({ "background": "#0d1117", "template": { "name": "badge_with_text", "shape": badge_shape, "text": badge_text, }, "lights": [ {"type": "ambient", "intensity": 0.4}, {"type": "directional", "color": "#ffffff", "intensity": 2.0, "position": [4, 6, 4]}, ], "animation": {"type": "rotate", "speed": 0.5, "axis": "y"}, }) note = "Template (badge with text)." elif any(pat in lo for pat in _NESTED_SPHERE_PATTERNS): scene = build_scene({ "background": "#0b0e14", "objects": [o.model_dump() for o in TEMPLATES["nested_spheres"]({})], "lights": [ {"type": "ambient", "intensity": 0.5}, {"type": "directional", "intensity": 1.3, "position": [5, 8, 6]}, ], "animation": {"type": "rotate", "speed": 0.8, "axis": "y"}, }) note = "Template (nested spheres)." else: try: if os.environ.get("MOCK"): raw = mock_scene_json(prompt) else: raw = _generate_json(prompt) if extract_json(raw) is None: log.warning("Bad JSON on first attempt — retrying") raw = _generate_json(prompt) note = "Rendered." except Exception as e: log.error("LLM error, falling back to mock: %s", e, exc_info=True) raw = mock_scene_json(prompt) note = f"Fallback ({type(e).__name__})." parsed = extract_json(raw) if parsed is None: log.warning("Both LLM attempts returned bad JSON — using mock") raw = mock_scene_json(prompt) note = "Fallback (bad JSON)." parsed = extract_json(raw) scene = build_scene(parsed) if motion != "auto": scene.animation.type = motion html = compile_html(scene, glow=glow, glow_strength=_glow, style=style, lighting=lighting, shadows=shadows) pretty = json.dumps(scene.model_dump(), indent=2) return iframe(html, height=600), html, pretty, note, scene def rerender(scene, glow: bool, glow_strength: float, style: str, motion: str, lighting: str, shadows: bool): if scene is None: return gr.update(), gr.update(), "Generate a scene first." _glow = glow_strength / 10.0 if motion != "auto": s = scene.model_copy(deep=True) s.animation.type = motion else: s = scene html = compile_html(s, glow=glow, glow_strength=_glow, style=style, lighting=lighting, shadows=shadows) return iframe(html, height=600), html, "Re-rendered." def _initial(): raw = mock_scene_json("a glass torus knot floating in the dark") scene = build_scene(extract_json(raw)) html = compile_html(scene) return (iframe(html, height=600), html, json.dumps(scene.model_dump(), indent=2), "Demo scene.", scene) # ---- Theme ---------------------------------------------------------------- def _build_theme(): return gr.themes.Base( primary_hue=gr.themes.colors.indigo, secondary_hue=gr.themes.colors.purple, neutral_hue=gr.themes.colors.slate, font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"], font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"], spacing_size=gr.themes.sizes.spacing_md, radius_size=gr.themes.sizes.radius_md, ).set( # Page / body body_background_fill="#06060e", body_background_fill_dark="#06060e", background_fill_primary="#06060e", background_fill_primary_dark="#06060e", background_fill_secondary="#0d0d1a", background_fill_secondary_dark="#0d0d1a", # Blocks / panels block_background_fill="#0f0f1c", block_background_fill_dark="#0f0f1c", block_border_color="rgba(255,255,255,0.07)", block_border_color_dark="rgba(255,255,255,0.07)", block_border_width="1px", block_label_background_fill="#0f0f1c", block_label_background_fill_dark="#0f0f1c", block_label_text_color="#6d6d96", block_label_text_color_dark="#6d6d96", block_title_text_color="#8080aa", block_title_text_color_dark="#8080aa", block_shadow="0 4px 24px rgba(0,0,0,0.4)", block_shadow_dark="0 4px 24px rgba(0,0,0,0.4)", block_radius="14px", block_padding="20px", # Body text body_text_color="#ddddf0", body_text_color_dark="#ddddf0", body_text_color_subdued="#7070a0", body_text_color_subdued_dark="#7070a0", # Inputs input_background_fill="#14142a", input_background_fill_dark="#14142a", input_background_fill_focus="#1a1a32", input_background_fill_focus_dark="#1a1a32", input_border_color="rgba(255,255,255,0.09)", input_border_color_dark="rgba(255,255,255,0.09)", input_border_color_focus="#6366f1", input_border_color_focus_dark="#6366f1", input_shadow_focus="0 0 0 3px rgba(99,102,241,0.22)", input_shadow_focus_dark="0 0 0 3px rgba(99,102,241,0.22)", input_placeholder_color="#3d3d60", input_placeholder_color_dark="#3d3d60", input_radius="10px", # Primary button (Generate) button_primary_background_fill="linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%)", button_primary_background_fill_dark="linear-gradient(135deg,#6366f1 0%,#8b5cf6 100%)", button_primary_background_fill_hover="linear-gradient(135deg,#818cf8 0%,#a78bfa 100%)", button_primary_background_fill_hover_dark="linear-gradient(135deg,#818cf8 0%,#a78bfa 100%)", button_primary_text_color="#ffffff", button_primary_text_color_dark="#ffffff", button_primary_border_color="transparent", button_primary_border_color_dark="transparent", button_primary_shadow="0 4px 20px rgba(99,102,241,0.4)", button_primary_shadow_dark="0 4px 20px rgba(99,102,241,0.4)", button_primary_shadow_hover="0 6px 28px rgba(99,102,241,0.55)", button_primary_shadow_hover_dark="0 6px 28px rgba(99,102,241,0.55)", button_transform_hover="translateY(-1px)", button_large_padding="14px 24px", button_large_text_size="15px", button_large_text_weight="600", button_medium_text_weight="500", # Secondary button button_secondary_background_fill="#161630", button_secondary_background_fill_dark="#161630", button_secondary_background_fill_hover="#1c1c3a", button_secondary_background_fill_hover_dark="#1c1c3a", button_secondary_text_color="#c0c0e0", button_secondary_text_color_dark="#c0c0e0", button_secondary_border_color="rgba(255,255,255,0.08)", button_secondary_border_color_dark="rgba(255,255,255,0.08)", # Slider slider_color="#6366f1", slider_color_dark="#6366f1", # Checkbox checkbox_background_color="#14142a", checkbox_background_color_dark="#14142a", checkbox_border_color="rgba(255,255,255,0.14)", checkbox_border_color_dark="rgba(255,255,255,0.14)", checkbox_background_color_selected="#6366f1", checkbox_background_color_selected_dark="#6366f1", checkbox_border_color_selected="#6366f1", checkbox_border_color_selected_dark="#6366f1", checkbox_border_color_focus="#6366f1", checkbox_border_color_focus_dark="#6366f1", checkbox_label_background_fill="#14142a", checkbox_label_background_fill_dark="#14142a", checkbox_label_background_fill_hover="#1a1a32", checkbox_label_background_fill_hover_dark="#1a1a32", checkbox_label_text_color="#c0c0e0", checkbox_label_text_color_dark="#c0c0e0", # Accent color_accent="#6366f1", color_accent_soft="rgba(99,102,241,0.15)", color_accent_soft_dark="rgba(99,102,241,0.15)", border_color_accent="#6366f1", border_color_accent_dark="#6366f1", border_color_primary="rgba(255,255,255,0.07)", border_color_primary_dark="rgba(255,255,255,0.07)", # Panel panel_background_fill="#0d0d1a", panel_background_fill_dark="#0d0d1a", panel_border_color="rgba(255,255,255,0.07)", panel_border_color_dark="rgba(255,255,255,0.07)", # Code code_background_fill="#0a0a18", code_background_fill_dark="#0a0a18", # Shadows shadow_drop="0 2px 8px rgba(0,0,0,0.4)", shadow_drop_lg="0 8px 32px rgba(0,0,0,0.6)", ) # ---- CSS ------------------------------------------------------------------ _CSS = """ /* ── Google Fonts ── */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); /* ── Root tokens ── */ :root { --tg-accent: #6366f1; --tg-accent-hi: #818cf8; --tg-accent-glow: rgba(99, 102, 241, 0.35); --tg-accent-soft: rgba(99, 102, 241, 0.13); --tg-bg: #06060e; --tg-card: #0f0f1c; --tg-elevated: #161630; --tg-border: rgba(255, 255, 255, 0.07); --tg-border-hi: rgba(255, 255, 255, 0.12); --tg-text: #ddddf0; --tg-text-dim: #7070a0; --tg-text-muted: #38385a; --tg-r: 14px; } /* ── Foundation ── */ body { background: var(--tg-bg) !important; font-family: 'Inter', -apple-system, sans-serif !important; } footer { display: none !important; } .built-with { display: none !important; } .gradio-container { max-width: 1460px !important; background: transparent !important; font-family: 'Inter', -apple-system, sans-serif !important; padding: 0 28px !important; } /* ── Header wrapper (Gradio block containing the header HTML) ── */ #tg-header, #tg-header > * { background: transparent !important; border: none !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; } /* ── Card groups ── */ .tg-card { background: var(--tg-card) !important; border: 1px solid var(--tg-border) !important; border-radius: var(--tg-r) !important; box-shadow: 0 4px 28px rgba(0,0,0,0.4) !important; overflow: hidden !important; margin-bottom: 12px !important; } .tg-card .block, .tg-card .form, .tg-card .gap { background: transparent !important; border: none !important; } /* Section header labels inside cards */ .tg-card .tg-lbl { display: block !important; font-size: 10px !important; font-weight: 700 !important; letter-spacing: 0.1em !important; text-transform: uppercase !important; color: var(--tg-text-dim) !important; padding: 14px 20px 10px !important; border-bottom: 1px solid var(--tg-border) !important; margin-bottom: 2px !important; background: transparent !important; } /* ── Transparent misc containers ── */ .block, .form, .gap { background: transparent !important; } /* ── Generate button ── */ #tg-generate { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important; border: none !important; border-radius: 10px !important; color: #fff !important; font-size: 15px !important; font-weight: 600 !important; letter-spacing: 0.025em !important; padding: 14px 24px !important; width: 100% !important; box-shadow: 0 4px 20px rgba(99,102,241,0.42) !important; transition: all 0.2s ease !important; } #tg-generate:hover { background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%) !important; box-shadow: 0 6px 32px rgba(99,102,241,0.6) !important; transform: translateY(-1px) !important; } #tg-generate:active { transform: translateY(0) !important; } /* ── Prompt textarea ── */ #tg-prompt textarea { min-height: 80px !important; font-size: 14px !important; line-height: 1.6 !important; } /* ── All labels / block titles ── */ .block > .label-wrap span, .block > label > span, span.svelte-1gfkn6j, .block-label span { font-size: 11px !important; font-weight: 600 !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; color: var(--tg-text-dim) !important; } /* ── Examples as chips ── */ .examples { background: transparent !important; } .examples table { background: transparent !important; border-spacing: 5px !important; border-collapse: separate !important; } .examples thead, .examples th { display: none !important; } .examples td { padding: 2px !important; background: transparent !important; border: none !important; } .examples td button, .examples .example { background: var(--tg-elevated) !important; border: 1px solid var(--tg-border) !important; border-radius: 999px !important; color: var(--tg-text-dim) !important; font-family: 'Inter', sans-serif !important; font-size: 12px !important; font-weight: 400 !important; padding: 5px 14px !important; white-space: nowrap !important; cursor: pointer !important; transition: all 0.15s ease !important; } .examples td button:hover, .examples .example:hover { background: var(--tg-accent-soft) !important; border-color: rgba(99,102,241,0.4) !important; color: var(--tg-text) !important; } /* ── Slider thumb ── */ input[type="range"]::-webkit-slider-thumb { background: var(--tg-accent) !important; box-shadow: 0 0 8px var(--tg-accent-glow) !important; } input[type="range"]::-moz-range-thumb { background: var(--tg-accent) !important; } /* ── Tab bar ── */ .tab-nav { background: transparent !important; border-bottom: 1px solid var(--tg-border) !important; padding: 0 6px !important; } .tab-nav button { background: transparent !important; border: none !important; border-bottom: 2px solid transparent !important; border-radius: 0 !important; color: var(--tg-text-dim) !important; font-family: 'Inter', sans-serif !important; font-size: 13px !important; font-weight: 500 !important; padding: 11px 18px !important; margin-bottom: -1px !important; transition: color 0.15s, border-color 0.15s !important; } .tab-nav button.selected { color: var(--tg-accent-hi) !important; border-bottom-color: var(--tg-accent) !important; } .tab-nav button:hover:not(.selected) { color: var(--tg-text) !important; } /* ── Preview panel container ── */ #tg-preview-col > .block { background: transparent !important; border: none !important; } #tg-preview-col .tabs { background: var(--tg-card) !important; border: 1px solid var(--tg-border) !important; border-radius: var(--tg-r) !important; box-shadow: 0 8px 40px rgba(0,0,0,0.5) !important; overflow: hidden !important; } #tg-preview-col .tabitem { background: transparent !important; border: none !important; } /* ── Preview iframe ── */ #tg-preview .gr-html, #tg-preview > div { background: #000 !important; border-radius: 0 !important; } #tg-preview iframe { display: block !important; border-radius: 0 !important; } /* ── Status line ── */ #tg-status .prose, #tg-status p { color: var(--tg-text-muted) !important; font-size: 12px !important; line-height: 1.4 !important; font-variant-numeric: tabular-nums !important; background: transparent !important; } /* ── Scene JSON accordion ── */ #tg-json { background: var(--tg-card) !important; border: 1px solid var(--tg-border) !important; border-radius: var(--tg-r) !important; margin-top: 16px !important; box-shadow: 0 4px 24px rgba(0,0,0,0.35) !important; } #tg-json > * { background: transparent !important; } /* ── Code tab: fixed height, internal scroll ── */ #tg-code, #tg-code .code-wrap, #tg-code .cm-editor, #tg-code .codemirror-wrapper { max-height: 480px !important; overflow: auto !important; } /* Fallback: any code editor inside the preview column tabs */ #tg-preview-col .code-wrap, #tg-preview-col .cm-scroller { max-height: 480px !important; overflow: auto !important; } /* ── Code block ── */ .codemirror-wrapper, .cm-editor { background: #08081a !important; border-radius: 10px !important; } /* ── Thin scrollbars ── */ * { scrollbar-width: thin; scrollbar-color: var(--tg-elevated) transparent; } ::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--tg-elevated); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--tg-accent); } /* ── Mobile ── */ @media (max-width: 768px) { .gradio-container { padding: 0 12px !important; } #tg-header { margin-bottom: 18px !important; } .tg-card { border-radius: 10px !important; } .gradio-container .row { flex-wrap: wrap !important; } } """ # ---- HTML fragments ------------------------------------------------------- _HEADER_HTML = """