ThreeGen / app.py
bolajiev
UI fixes: example list, code scroll, header containment
d8f1c3d
Raw
History Blame Contribute Delete
25.5 kB
"""
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 = """
<div style="display:flex;align-items:center;gap:18px;
padding:28px 0 24px;
border-bottom:1px solid rgba(255,255,255,0.07);
margin-bottom:28px;">
<svg width="42" height="42" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="42" height="42" rx="11" fill="url(#hg)"/>
<polygon points="21,7 34,14.5 34,27.5 21,35 8,27.5 8,14.5"
fill="none" stroke="rgba(255,255,255,0.85)" stroke-width="1.8"/>
<line x1="21" y1="7" x2="21" y2="21" stroke="rgba(255,255,255,0.45)" stroke-width="1.4"/>
<line x1="34" y1="14.5" x2="21" y2="21" stroke="rgba(255,255,255,0.45)" stroke-width="1.4"/>
<line x1="8" y1="14.5" x2="21" y2="21" stroke="rgba(255,255,255,0.45)" stroke-width="1.4"/>
<defs>
<linearGradient id="hg" x1="0" y1="0" x2="42" y2="42" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>
<div>
<div style="font-size:22px;font-weight:700;color:#e2e2f8;letter-spacing:-0.02em;
line-height:1;font-family:'Inter',sans-serif;">ThreeGen</div>
<div style="margin-top:5px;font-size:13px;color:#5a5a8a;font-weight:400;
font-family:'Inter',sans-serif;letter-spacing:0.01em;">
Describe a 3D scene &mdash; get sharable Three.js in seconds
</div>
</div>
</div>
"""
_CARD_DESCRIBE = '<span class="tg-lbl">Describe</span>'
_CARD_RENDER = '<span class="tg-lbl">Render style</span>'
_CARD_POST = '<span class="tg-lbl">Post-processing</span>'
# ---- UI -------------------------------------------------------------------
with gr.Blocks(title="ThreeGen", theme=_build_theme(), css=_CSS) as demo:
# Header
gr.HTML(_HEADER_HTML, elem_id="tg-header")
with gr.Row(equal_height=False):
# ── Controls column ──────────────────────────────────────────────
with gr.Column(scale=5, min_width=320):
# Card: Describe
with gr.Group(elem_classes="tg-card"):
gr.HTML(_CARD_DESCRIBE)
prompt = gr.Textbox(
label="",
lines=3,
placeholder="neon low-poly tree, slowly rotating…",
elem_id="tg-prompt",
show_label=False,
)
btn = gr.Button("Generate", variant="primary",
elem_id="tg-generate", size="lg")
gr.Examples(
examples=EXAMPLES,
inputs=prompt,
label="",
)
# Card: Render style
with gr.Group(elem_classes="tg-card"):
gr.HTML(_CARD_RENDER)
with gr.Row():
style = gr.Dropdown(
["realistic", "flat", "wireframe", "toon"],
value="realistic", label="Style", scale=1)
motion = gr.Dropdown(
["auto", "none", "rotate", "float", "orbit"],
value="auto", label="Motion", scale=1)
with gr.Row():
lighting = gr.Dropdown(
["studio", "soft", "neon", "dramatic"],
value="studio", label="Lighting", scale=1)
shadows = gr.Checkbox(label="Shadows", value=True, scale=1)
# Card: Post-processing
with gr.Group(elem_classes="tg-card"):
gr.HTML(_CARD_POST)
with gr.Row():
glow = gr.Checkbox(label="Bloom / glow", value=True, scale=0)
glow_strength = gr.Slider(
0, 20, value=10, step=1,
label="Glow strength (10 = default)",
scale=2,
)
status = gr.Markdown("", elem_id="tg-status")
# ── Preview column ───────────────────────────────────────────────
with gr.Column(scale=7, min_width=400, elem_id="tg-preview-col"):
with gr.Tabs():
with gr.Tab("Preview"):
preview = gr.HTML(elem_id="tg-preview")
with gr.Tab("Code"):
code = gr.Code(
language="html",
label="",
show_label=False,
elem_id="tg-code",
)
# Scene JSON (collapsed)
with gr.Accordion("Scene JSON (raw model output)", open=False, elem_id="tg-json"):
scene_json = gr.Code(language="json", label="", show_label=False)
scene_state = gr.State(None)
# ── Wire events ─────────────────────────────────────────────────────
gen_inputs = [prompt, glow, glow_strength, style, motion, lighting, shadows]
gen_outputs = [preview, code, scene_json, status, scene_state]
btn.click(generate, gen_inputs, gen_outputs)
prompt.submit(generate, gen_inputs, gen_outputs)
demo.load(_initial, None, gen_outputs)
re_inputs = [scene_state, glow, glow_strength, style, motion, lighting, shadows]
re_outputs = [preview, code, status]
style.change(rerender, re_inputs, re_outputs)
motion.change(rerender, re_inputs, re_outputs)
lighting.change(rerender, re_inputs, re_outputs)
shadows.change(rerender, re_inputs, re_outputs)
glow.change(rerender, re_inputs, re_outputs)
glow_strength.change(rerender, re_inputs, re_outputs)
if __name__ == "__main__":
demo.launch()