upcycle-things / app.py
Tekrox's picture
Initial upload: Upcycle Things
758cd30 verified
Raw
History Blame Contribute Delete
33.1 kB
import html
import json
import os
import tempfile
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
import gradio as gr
from gradio.themes import Base
from gallery.gallery import append_exhibit, load_exhibits, count_exhibits
# ── Palette ────────────────────────────────────────────────────────────────────
ACCENT = "#52B788"
ACCENT_DARK = "#3D9B70"
ACCENT_BRIGHT= "#6EE8A4"
BG = "#0D1A12" # dark forest
SURFACE = "#162A1C" # card / panel
SURFACE2 = "#1E3526" # slightly raised surface
BORDER = "#2D4A36" # borders
TEXT = "#D6F5E3" # primary bright text
TEXT_DIM = "#6BA882" # secondary / muted text
# ── Theme ──────────────────────────────────────────────────────────────────────
MUSEUM_THEME = Base(
font=[gr.themes.GoogleFont("DM Sans"), "system-ui", "sans-serif"],
).set(
body_background_fill=BG,
body_text_color=TEXT,
background_fill_primary=SURFACE,
background_fill_secondary=BG,
border_color_primary=BORDER,
button_primary_background_fill=ACCENT,
button_primary_background_fill_hover=ACCENT_DARK,
button_primary_text_color="#FFFFFF",
button_primary_border_color=ACCENT,
button_secondary_background_fill=SURFACE2,
button_secondary_text_color=TEXT_DIM,
button_secondary_border_color=BORDER,
input_background_fill=SURFACE,
input_border_color=BORDER,
block_border_color="transparent",
block_background_fill="transparent",
shadow_drop="none",
shadow_spread="0px",
)
APP_CSS = """
/* ── Base ── */
.gradio-container {
max-width: 1400px !important;
margin: 0 auto !important;
padding: 0 0 48px 0 !important;
background: #0D1A12 !important;
}
body { background: #0D1A12 !important; }
footer { display: none !important; }
.block { padding: 0 !important; border: none !important; background: transparent !important; box-shadow: none !important; }
.gap, .gr-group { gap: 0 !important; }
.label-wrap { display: none !important; }
/* ── Hide Gradio tab nav (single-page, no tabs) ── */
.tabs > .tab-nav { display: none !important; }
/* ── App bar ── */
.mot-appbar {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 40px 14px; background: rgba(13,26,18,0.97);
border-bottom: 1px solid #2D4A36;
position: sticky; top: 0; z-index: 100;
backdrop-filter: blur(10px);
font-family: 'DM Sans', sans-serif;
}
.mot-logo { font-size: 20px; font-weight: 700; color: #D6F5E3; letter-spacing: -0.5px;
display: flex; align-items: center; gap: 8px; }
.mot-logo-dot { width: 10px; height: 10px; border-radius: 50%; background: #52B788; }
/* ── Hero + upload row ── */
.mot-hero-col { padding: 0 !important; }
.mot-upload-col {
padding: 36px 40px !important;
background: #111F16 !important;
border-left: 1px solid #2D4A36 !important;
}
/* ── Upload zone ── */
.mot-upload { padding: 0 !important; }
.mot-upload .upload-container,
.mot-upload .image-frame,
.mot-upload .wrap {
border: 2px dashed #2D4A36 !important;
border-radius: 18px !important;
background: #0D1A12 !important;
min-height: 200px !important;
}
.mot-upload img { border-radius: 16px !important; }
/* ── Buttons ── */
.mot-submit { padding: 20px 0 0 !important; }
.mot-submit button {
border-radius: 16px !important; font-size: 16px !important;
font-weight: 600 !important; min-height: 54px !important;
box-shadow: 0 4px 20px rgba(82,183,136,0.25) !important;
}
.mot-share { padding: 12px 0 0 !important; }
.mot-share button { border-radius: 14px !important; min-height: 46px !important; font-weight: 600 !important; }
/* ── Results row ── */
.mot-results-row {
border-top: 1px solid #2D4A36 !important;
border-bottom: 1px solid #2D4A36 !important;
background: #111F16 !important;
}
.mot-slider-col { padding: 36px 20px 36px 40px !important; }
.mot-phases-col { padding: 36px 40px 36px 20px !important; }
/* ── Gallery section ── */
.mot-gallery-section { padding: 0 40px !important; }
/* ── Image slider ── */
.mot-slider .image-slider { border-radius: 16px !important; overflow: hidden !important; }
/* ── Accordion ── */
.gr-accordion { background: #0D1A12 !important; border: 1px solid #2D4A36 !important; border-radius: 12px !important; }
/* ── Refresh ── */
.mot-refresh { padding: 16px 0 !important; }
.mot-refresh button { border-radius: 12px !important; font-size: 13px !important; }
.mot-spacer { height: 12px !important; }
"""
# ── Static HTML ────────────────────────────────────────────────────────────────
APPBAR_HTML = """
<div class="mot-appbar">
<div class="mot-logo">Upcycle Things<div class="mot-logo-dot"></div></div>
<span style="font-size:13px;color:#4A7A5C;font-family:'DM Sans',sans-serif">Build Small Hackathon · June 2026</span>
</div>
<script>
function motOpenCard(d){
var ta=document.querySelector('#exhibit-select textarea');
if(!ta)return;
var setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
setter.call(ta,JSON.stringify(d));
ta.dispatchEvent(new Event('input',{bubbles:true}));
ta.dispatchEvent(new Event('change',{bubbles:true}));
setTimeout(function(){
var el=document.getElementById('mot-results');
if(el)el.scrollIntoView({behavior:'smooth',block:'start'});
},400);
}
</script>
"""
STEP1_HTML = """
<p style="font-family:'DM Sans',sans-serif;font-size:11px;font-weight:600;
color:#4A7A5C;letter-spacing:0.08em;text-transform:uppercase;
padding:0 0 8px;margin:0">1 — Upload a photo</p>
"""
STEP2_HTML = """
<p style="font-family:'DM Sans',sans-serif;font-size:11px;font-weight:600;
color:#4A7A5C;letter-spacing:0.08em;text-transform:uppercase;
padding:20px 0 8px;margin:0">2 — What should it become?</p>
"""
def _status_html(active: int) -> str:
steps = [
"Analyzing your object",
"Designing the transformation",
"Rendering the new life",
]
rows = ""
for i, label in enumerate(steps, 1):
if i < active:
rows += (
f'<div style="display:flex;align-items:center;gap:14px;padding:13px 0;'
f'border-bottom:1px solid #1E3526">'
f'<span style="font-size:15px;color:#52B788;min-width:22px;text-align:center">✓</span>'
f'<span style="font-size:14px;color:#6BA882;font-family:DM Sans,sans-serif">'
f'{i}{label}</span></div>'
)
elif i == active:
rows += (
f'<div style="display:flex;align-items:center;gap:14px;padding:13px 0;'
f'border-bottom:1px solid #2D4A36">'
f'<span style="font-size:15px;color:#6EE8A4;min-width:22px;text-align:center;'
f'font-weight:700">›</span>'
f'<span style="font-size:15px;font-weight:600;color:#D6F5E3;'
f'font-family:DM Sans,sans-serif">{i}{label}…</span></div>'
)
else:
rows += (
f'<div style="display:flex;align-items:center;gap:14px;padding:13px 0;'
f'border-bottom:1px solid #162A1C;opacity:0.3">'
f'<span style="font-size:14px;color:#4A7A5C;min-width:22px;text-align:center">{i}</span>'
f'<span style="font-size:14px;color:#4A7A5C;font-family:DM Sans,sans-serif">'
f'{label}</span></div>'
)
return (
f'<div style="font-family:DM Sans,sans-serif;padding:32px 18px 8px">'
f'<p style="font-size:11px;font-weight:600;color:#4A7A5C;letter-spacing:0.1em;'
f'text-transform:uppercase;margin:0 0 18px">Working on it</p>'
f'{rows}</div>'
)
def _results_html(new_name: str, tagline: str, phase1: str, phase2: str, phase3: str,
orig_name: str = "", orig_material: str = "") -> str:
if not new_name:
return ""
def phase_card(icon, title, body):
if not body:
return ""
rows = "".join(
f'<div style="font-size:13px;color:#D6F5E3;line-height:1.75;'
f'border-bottom:1px solid #2D4A36;padding:6px 0">'
f'{line.lstrip("-•123456789. ").strip()}</div>'
for line in body.splitlines()
if line.strip() and line.strip() not in ("-", "•")
)
return (
f'<div style="background:#162A1C;border:1px solid #2D4A36;border-radius:16px;'
f'padding:14px 16px;margin-bottom:10px;border-left:3px solid #52B788;'
f'box-shadow:0 1px 4px rgba(0,0,0,0.2)">'
f'<div style="display:flex;align-items:center;gap:8px;font-size:11px;'
f'font-weight:700;color:#4A7A5C;letter-spacing:0.08em;text-transform:uppercase;'
f'margin-bottom:10px">{icon} <span style="color:#6BA882">{title}</span></div>'
f'{rows}</div>'
)
return (
f'<div style="font-family:\'DM Sans\',sans-serif;padding:18px 18px 6px">'
f'<h2 style="font-size:24px;font-weight:700;color:#D6F5E3;'
f'letter-spacing:-0.5px;margin:0 0 4px">{new_name}</h2>'
f'<p style="font-size:14px;color:#52B788;font-style:italic;margin:0 0 8px;line-height:1.5">'
f'{tagline}</p>'
+ (
f'<p style="font-size:11px;color:#4A7A5C;margin:0 0 2px;letter-spacing:0.05em">'
f'Identified: {html.escape(orig_name)} · {html.escape(orig_material)}</p>'
if orig_name else ""
)
+ f'</div>'
f'<div style="padding:6px 18px 0;font-family:\'DM Sans\',sans-serif">'
f'{phase_card("🔧", "Disassemble", phase1)}'
f'{phase_card("🛒", "What you need", phase2)}'
f'{phase_card("🔨", "Build it", phase3)}'
f'</div>'
)
def _parse_instructions(text: str) -> tuple[str, str, str]:
"""Return (phase_1, phase_2, phase_3) from raw LLM output."""
result = {"phase_1": "", "phase_2": "", "phase_3": ""}
current = None
buf: list[str] = []
def flush():
if current and buf:
result[current] = "\n".join(buf).strip()
for line in text.splitlines():
s = line.strip()
if "PHASE 1" in s:
flush(); buf = []; current = "phase_1"
elif "PHASE 2" in s:
flush(); buf = []; current = "phase_2"
elif "PHASE 3" in s:
flush(); buf = []; current = "phase_3"
elif current and s:
buf.append(s)
flush()
return result["phase_1"], result["phase_2"], result["phase_3"]
def _fmt_phase(body: str) -> str:
if not body:
return ""
return "<br>".join(
line.lstrip("-•123456789. ").strip()
for line in body.splitlines()
if line.strip() and line.strip() not in ("-", "•")
)
def _home_feed_html() -> str:
exhibits = load_exhibits()
recent = [e for e in exhibits if e.get("portrait_url")][:9]
if not recent:
return ""
cards = []
for e in recent:
img = e.get("portrait_url", "")
name = e.get("name", "Unknown")
tname = e.get("transformation_name") or name
tagline = e.get("tagline", "")
p1, p2, p3 = _parse_instructions(e.get("instructions", ""))
original = e.get("original_url", "")
data_attr = html.escape(json.dumps({
"img": img, "original": original,
"name": name, "tname": tname, "tagline": tagline,
"p1": _fmt_phase(p1), "p2": _fmt_phase(p2), "p3": _fmt_phase(p3),
}, ensure_ascii=False))
cards.append(
f'<div onclick="motOpenCard(JSON.parse(this.getAttribute(\'data-exhibit\')))"'
f' data-exhibit="{data_attr}"'
f' style="background:#162A1C;border-radius:16px;overflow:hidden;cursor:pointer;'
f'box-shadow:0 2px 16px rgba(0,0,0,0.35);break-inside:avoid;margin-bottom:12px">'
f'<img src="{img}" style="width:100%;aspect-ratio:1;object-fit:cover;display:block">'
f'<div style="padding:10px 12px 12px">'
f'<p style="font-size:10px;color:#4A7A5C;margin:0 0 2px;letter-spacing:0.06em;text-transform:uppercase">{name}</p>'
f'<p style="font-size:13px;font-weight:600;color:#D6F5E3;margin:0 0 3px;line-height:1.3">→ {tname}</p>'
f'<p style="font-size:12px;color:#6BA882;margin:0;font-style:italic;line-height:1.4">{tagline}</p>'
f'</div></div>'
)
cols_a = "".join(cards[0::3])
cols_b = "".join(cards[1::3])
cols_c = "".join(cards[2::3])
return (
f'<div style="padding:0 32px;font-family:\'DM Sans\',sans-serif">'
f'<p style="font-size:11px;font-weight:600;color:#4A7A5C;letter-spacing:0.08em;'
f'text-transform:uppercase;margin:0 0 16px">Recent ideas</p>'
f'<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;align-items:start">'
f'<div>{cols_a}</div>'
f'<div style="margin-top:32px">{cols_b}</div>'
f'<div style="margin-top:16px">{cols_c}</div>'
f'</div></div>'
)
def _home_hero_html() -> str:
return (
'<div style="font-family:\'DM Sans\',sans-serif;padding:56px 40px 48px;height:100%;'
'display:flex;flex-direction:column;justify-content:center">'
'<p style="font-size:11px;font-weight:600;color:#4A7A5C;letter-spacing:0.12em;'
'text-transform:uppercase;margin:0 0 20px">AI Upcycling</p>'
'<h1 style="font-size:52px;font-weight:700;line-height:1.08;'
'letter-spacing:-1.5px;color:#D6F5E3;margin:0 0 20px">'
'Give your things<br>a <em style="font-style:normal;color:#6EE8A4">second life.</em></h1>'
'<p style="font-size:17px;color:#6BA882;line-height:1.7;margin:0 0 40px;max-width:420px">'
'Upload any object you\'d throw away. Our AI identifies the parts, '
'picks a transformation, and shows you how to build it.</p>'
'<div style="display:flex;gap:24px">'
'<div style="text-align:center">'
'<div style="font-size:28px;font-weight:700;color:#6EE8A4">AI</div>'
'<div style="font-size:12px;color:#4A7A5C;margin-top:2px">Vision model</div>'
'</div>'
'<div style="width:1px;background:#2D4A36"></div>'
'<div style="text-align:center">'
'<div style="font-size:28px;font-weight:700;color:#6EE8A4">5</div>'
'<div style="font-size:12px;color:#4A7A5C;margin-top:2px">Categories</div>'
'</div>'
'<div style="width:1px;background:#2D4A36"></div>'
'<div style="text-align:center">'
'<div style="font-size:28px;font-weight:700;color:#6EE8A4">0%</div>'
'<div style="font-size:12px;color:#4A7A5C;margin-top:2px">Waste</div>'
'</div>'
'</div>'
'</div>'
)
def _gallery_html() -> str:
exhibits = load_exhibits()
count = count_exhibits()
cards = []
for e in exhibits:
img = e.get("portrait_url", "")
if not img:
continue
name = e.get("name", "Unknown")
tname = e.get("transformation_name") or name
tagline = e.get("tagline", "")
p1, p2, p3 = _parse_instructions(e.get("instructions", ""))
original = e.get("original_url", "")
data_attr = html.escape(json.dumps({
"img": img, "original": original,
"name": name, "tname": tname, "tagline": tagline,
"p1": _fmt_phase(p1), "p2": _fmt_phase(p2), "p3": _fmt_phase(p3),
}, ensure_ascii=False))
cards.append(
f'<div onclick="motOpenCard(JSON.parse(this.getAttribute(\'data-exhibit\')))"'
f' data-exhibit="{data_attr}"'
f' style="background:#162A1C;border-radius:16px;overflow:hidden;cursor:pointer;'
f'box-shadow:0 2px 16px rgba(0,0,0,0.35);break-inside:avoid;margin-bottom:12px">'
f'<img src="{img}" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;display:block">'
f'<div style="padding:10px 12px 12px">'
f'<p style="font-size:10px;color:#4A7A5C;margin:0 0 2px;letter-spacing:0.06em;text-transform:uppercase">{name}</p>'
f'<p style="font-size:13px;font-weight:600;color:#D6F5E3;margin:0 0 3px;line-height:1.3">→ {tname}</p>'
f'<p style="font-size:12px;color:#6BA882;margin:0;font-style:italic;line-height:1.4">{tagline}</p>'
f'</div></div>'
)
if cards:
cols_a = "".join(cards[0::3])
cols_b = "".join(cards[1::3])
cols_c = "".join(cards[2::3])
grid = (
f'<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;'
f'align-items:start;padding:0">'
f'<div>{cols_a}</div>'
f'<div style="margin-top:24px">{cols_b}</div>'
f'<div style="margin-top:12px">{cols_c}</div>'
f'</div>'
)
else:
grid = (
'<p style="text-align:center;color:#4A7A5C;font-style:italic;padding:3em 1em;'
'font-size:14px;font-family:DM Sans,sans-serif">No transformations yet. Be the first.</p>'
)
return (
f'<div style="font-family:\'DM Sans\',sans-serif">'
f'<div style="padding:48px 0 16px;display:flex;align-items:baseline;justify-content:space-between">'
f'<div>'
f'<h2 style="font-size:30px;font-weight:700;color:#D6F5E3;letter-spacing:-0.7px;margin:0 0 4px">Community transformations</h2>'
f'<p style="font-size:14px;color:#4A7A5C;margin:0">Things people gave a second life</p>'
f'</div>'
f'<span style="font-size:13px;color:#4A7A5C;font-weight:500">{count} thing{"s" if count != 1 else ""}</span>'
f'</div>'
f'<div style="display:flex;gap:7px;overflow-x:auto;padding:0 0 14px;scrollbar-width:none">'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#52B788;'
f'color:#0D1A12;font-family:DM Sans,sans-serif;font-size:13px;font-weight:600;border:none">All</button>'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#1E3526;'
f'color:#6BA882;font-family:DM Sans,sans-serif;font-size:13px;border:1px solid #2D4A36">Lamp</button>'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#1E3526;'
f'color:#6BA882;font-family:DM Sans,sans-serif;font-size:13px;border:1px solid #2D4A36">Kids toy</button>'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#1E3526;'
f'color:#6BA882;font-family:DM Sans,sans-serif;font-size:13px;border:1px solid #2D4A36">Wall art</button>'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#1E3526;'
f'color:#6BA882;font-family:DM Sans,sans-serif;font-size:13px;border:1px solid #2D4A36">Flowerpot</button>'
f'<button style="flex-shrink:0;padding:6px 14px;border-radius:20px;background:#1E3526;'
f'color:#6BA882;font-family:DM Sans,sans-serif;font-size:13px;border:1px solid #2D4A36">Clothes</button>'
f'</div>'
f'{grid}'
f'</div>'
)
def _exhibit_phase_html(name: str, tname: str, tagline: str, p1: str, p2: str, p3: str) -> str:
def phase_block(icon, title, body):
if not body:
return ""
return (
f'<div style="margin-bottom:18px;padding-bottom:18px;border-bottom:1px solid #2D4A36">'
f'<p style="font-size:11px;font-weight:700;color:#6BA882;letter-spacing:0.08em;'
f'text-transform:uppercase;margin:0 0 10px;font-family:DM Sans,sans-serif">{icon} {title}</p>'
f'<div style="font-size:14px;color:#D6F5E3;line-height:1.85;font-family:DM Sans,sans-serif">{body}</div>'
f'</div>'
)
if not (p1 or p2 or p3):
return (
'<p style="color:#4A7A5C;font-style:italic;padding:20px 0;font-family:DM Sans,sans-serif;font-size:14px">'
'No step-by-step guide available for this item.</p>'
)
return (
f'<div style="margin-top:20px">'
+ phase_block("🔧", "Disassemble", p1)
+ phase_block("🛒", "What you need", p2)
+ phase_block("🔨", "Build it", p3)
+ f'</div>'
)
def load_exhibit_detail(exhibit_json: str):
placeholder = (
'<p style="color:#4A7A5C;font-style:italic;padding:40px 0;text-align:center;'
'font-family:DM Sans,sans-serif;font-size:14px">Click any card to see the transformation.</p>'
)
if not exhibit_json:
return gr.update(visible=False), gr.update(visible=False), placeholder, gr.update(visible=False)
try:
d = json.loads(exhibit_json)
except Exception:
return gr.update(visible=False), gr.update(visible=False), placeholder, gr.update(visible=False)
portrait = d.get("img", "")
original = d.get("original", "")
if not portrait:
return gr.update(visible=False), gr.update(visible=False), placeholder, gr.update(visible=False)
before = gr.update(value=original if original else portrait, visible=True)
after = gr.update(value=portrait, visible=True)
name = d.get("name", "")
tname = d.get("tname", "")
tagline = d.get("tagline", "")
p1, p2, p3 = d.get("p1", ""), d.get("p2", ""), d.get("p3", "")
header = (
f'<div style="font-family:\'DM Sans\',sans-serif;padding-top:16px">'
f'<p style="font-size:11px;color:#4A7A5C;letter-spacing:0.07em;text-transform:uppercase;margin:0 0 4px">{html.escape(name)} →</p>'
f'<h2 style="font-size:26px;font-weight:700;color:#D6F5E3;margin:0 0 6px;letter-spacing:-0.5px">{html.escape(tname)}</h2>'
f'<p style="color:#6EE8A4;font-size:15px;font-style:italic;margin:0 0 4px;line-height:1.5">{html.escape(tagline)}</p>'
f'</div>'
)
phases = _exhibit_phase_html(name, tname, tagline, p1, p2, p3)
return before, after, header + phases, gr.update(visible=True)
# ── Backend ────────────────────────────────────────────────────────────────────
def _analyze(image_path: str) -> dict:
import modal
cls = modal.Cls.from_name("museum-of-things", "VisionModel")
return cls().analyze.remote(Path(image_path).read_bytes())
def _transform(vision_result: dict, category: str) -> str:
import modal
cls = modal.Cls.from_name("museum-of-things", "MonologueModel")
return cls().generate.remote(vision_result, category)
def _build_image_prompt(vision_result: dict, transformation_text: str) -> str:
image_scene = ""
new_name = ""
for line in transformation_text.splitlines():
s = line.strip()
if s.startswith("IMAGE SCENE:"):
image_scene = s.replace("IMAGE SCENE:", "").strip()
elif s.startswith("NEW LIFE:"):
new_name = s.replace("NEW LIFE:", "").strip()
if not image_scene:
name = vision_result.get("name", "object")
image_scene = (
f"Transform the {name} in the image into a {new_name or 'upcycled object'}. "
f"Keep its original shape, colors, materials and proportions. Place it on a wooden table."
)
features = ", ".join(vision_result.get("distinctive_features", []))
if features:
return f"{image_scene} Preserve these details from the original photo: {features}."
return image_scene
def _render(image_path: str, vision_result: dict, transformation_text: str) -> tuple[str | None, str]:
import modal
prompt = _build_image_prompt(vision_result, transformation_text)
cls = modal.Cls.from_name("museum-of-things", "PortraitModel")
image_bytes = cls().generate.remote(Path(image_path).read_bytes(), prompt, vision_result.get("bbox"))
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg")
tmp.write(image_bytes)
tmp.close()
return tmp.name, prompt
def _parse_transformation(text: str) -> dict:
result = {"new_name": "", "tagline": "", "phase_1": "", "phase_2": "", "phase_3": ""}
current = None
buf: list[str] = []
def flush():
if current and buf:
result[current] = "\n".join(buf).strip()
for line in text.splitlines():
s = line.strip()
if s.startswith("NEW LIFE:"):
result["new_name"] = s.replace("NEW LIFE:", "").strip()
elif s.startswith("TAGLINE:"):
result["tagline"] = s.replace("TAGLINE:", "").strip()
elif "PHASE 1" in s:
flush(); buf = []; current = "phase_1"
elif "PHASE 2" in s:
flush(); buf = []; current = "phase_2"
elif "PHASE 3" in s:
flush(); buf = []; current = "phase_3"
elif current and s:
buf.append(s)
flush()
return result
def process_object(image_path, category):
_no_change = gr.update()
if image_path is None:
yield "", gr.update(visible=False), gr.update(visible=False), "", "", gr.update(visible=False), gr.update(visible=False)
return
# ── Step 1: Analyze ──────────────────────────────────────────────────────
yield _status_html(1), gr.update(value=image_path, visible=True), gr.update(visible=False), "", "", gr.update(visible=False), gr.update(visible=True)
try:
vision = _analyze(image_path)
except Exception as e:
yield (
f'<p style="color:#E05252;padding:18px;font-family:DM Sans,sans-serif">'
f'Vision error: {html.escape(str(e))}</p>',
_no_change, _no_change, "", "", gr.update(visible=False), gr.update(visible=True),
)
return
name = vision.get("name", "—")
material = vision.get("material", "—")
components = vision.get("components", [])
# ── Step 2: Transform ────────────────────────────────────────────────────
yield _status_html(2), _no_change, _no_change, _no_change, _no_change, gr.update(visible=False), _no_change
try:
transformation_text = _transform(vision, category or "Lamp")
except Exception as e:
transformation_text = f"Transformation error: {e}"
parsed = _parse_transformation(transformation_text)
new_name = parsed.get("new_name") or "New creation"
tagline = parsed.get("tagline") or ""
phase_1 = parsed.get("phase_1") or ""
phase_2 = parsed.get("phase_2") or ""
phase_3 = parsed.get("phase_3") or ""
# ── Step 3: Render ───────────────────────────────────────────────────────
yield _status_html(3), _no_change, _no_change, _no_change, _no_change, gr.update(visible=False), _no_change
render_path = None
image_prompt = _build_image_prompt(vision, transformation_text)
try:
render_path, image_prompt = _render(image_path, vision, transformation_text)
except Exception as e:
phase_3 += f"\n\n[Render error: {e}]"
state = json.dumps({
"name": name, "material": material, "components": components,
"transformation_name": new_name, "tagline": tagline,
"instructions": transformation_text,
"portrait_path": render_path or "",
"original_path": image_path,
})
results = _results_html(new_name, tagline, phase_1, phase_2, phase_3,
orig_name=name, orig_material=material)
debug = (
f"=== VISION ===\n{json.dumps(vision, indent=2, ensure_ascii=False)}\n\n"
f"=== LLM OUTPUT ===\n{transformation_text}\n\n"
f"=== IMAGE PROMPT ===\n{image_prompt}"
)
before = gr.update(value=image_path, visible=True)
after = gr.update(value=render_path, visible=True) if render_path else gr.update(visible=False)
yield results, before, after, debug, state, gr.update(visible=True), gr.update(visible=True)
def save_to_gallery(state_json):
if not state_json:
return "Generate a transformation first."
try:
s = json.loads(state_json)
append_exhibit(
name=s["name"], material=s["material"], components=s["components"],
transformation_name=s["transformation_name"], tagline=s["tagline"],
instructions=s["instructions"],
portrait_path=s.get("portrait_path") or None,
original_path=s.get("original_path") or None,
)
return "✓ Added to the gallery!"
except Exception as e:
return f"Error: {e}"
# ── App ────────────────────────────────────────────────────────────────────────
with gr.Blocks(title="Upcycle Things") as demo:
exhibit_state = gr.State("")
gr.HTML(APPBAR_HTML)
# ── Hero + upload ──────────────────────────────────────────────────────────
with gr.Row(equal_height=True):
with gr.Column(scale=1, elem_classes=["mot-hero-col"]):
gr.HTML(value=_home_hero_html())
with gr.Column(scale=1, elem_classes=["mot-upload-col"]):
gr.HTML(STEP1_HTML)
image_input = gr.Image(type="filepath", show_label=False, elem_classes=["mot-upload"])
gr.HTML(STEP2_HTML)
category_input = gr.Radio(
choices=[("🧸 Kids toy", "Kids toy"), ("🖼️ Wall art", "Wall art"),
("🪴 Flowerpot", "Flowerpot"), ("💡 Lamp", "Lamp"),
("👕 Clothes", "new clothing item or fashion accessory")],
value=None, show_label=False,
)
submit_btn = gr.Button("Find its second life →", variant="primary", elem_classes=["mot-submit"], interactive=False)
# ── Results (hidden until scan completes) ──────────────────────────────────
with gr.Row(visible=False, elem_id="mot-results", elem_classes=["mot-results-row"]) as results_row:
with gr.Column(scale=1, elem_classes=["mot-slider-col"]):
with gr.Row():
before_img = gr.Image(label="Original", interactive=False,
type="filepath", show_label=True, visible=False)
after_img = gr.Image(label="AI Vision", interactive=False,
type="filepath", show_label=True, visible=False)
with gr.Column(scale=1, elem_classes=["mot-phases-col"]):
results_display = gr.HTML("")
save_btn = gr.Button("Share to Gallery", variant="primary", visible=False, elem_classes=["mot-share"])
save_status = gr.Markdown("")
with gr.Accordion("Debug info", open=True):
prompt_out = gr.Textbox(show_label=False, lines=8, interactive=False)
# ── Gallery ────────────────────────────────────────────────────────────────
with gr.Row(elem_classes=["mot-gallery-section"]):
gallery_html = gr.HTML(value=_gallery_html())
with gr.Row():
refresh_btn = gr.Button("↻ Refresh gallery", size="sm", variant="secondary", elem_classes=["mot-refresh"])
exhibit_trigger = gr.Textbox(visible=False, elem_id="exhibit-select")
# ── Events ─────────────────────────────────────────────────────────────────
submit_btn.click(
fn=process_object,
inputs=[image_input, category_input],
outputs=[results_display, before_img, after_img, prompt_out, exhibit_state, save_btn, results_row],
)
category_input.change(
fn=lambda cat: gr.update(interactive=bool(cat)),
inputs=[category_input],
outputs=[submit_btn],
)
save_btn.click(fn=save_to_gallery, inputs=[exhibit_state], outputs=[save_status])
refresh_btn.click(fn=_gallery_html, outputs=[gallery_html])
exhibit_trigger.change(
fn=load_exhibit_detail,
inputs=[exhibit_trigger],
outputs=[before_img, after_img, results_display, results_row],
)
if __name__ == "__main__":
demo.launch(css=APP_CSS, theme=MUSEUM_THEME)