| 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 |
|
|
| |
| ACCENT = "#52B788" |
| ACCENT_DARK = "#3D9B70" |
| ACCENT_BRIGHT= "#6EE8A4" |
| BG = "#0D1A12" |
| SURFACE = "#162A1C" |
| SURFACE2 = "#1E3526" |
| BORDER = "#2D4A36" |
| TEXT = "#D6F5E3" |
| TEXT_DIM = "#6BA882" |
|
|
| |
| 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; } |
| """ |
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| 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", []) |
|
|
| |
| 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 "" |
|
|
| |
| 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}" |
|
|
|
|
| |
|
|
| with gr.Blocks(title="Upcycle Things") as demo: |
|
|
| exhibit_state = gr.State("") |
|
|
| gr.HTML(APPBAR_HTML) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|