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 = """
Build Small Hackathon · June 2026
""" STEP1_HTML = """

1 — Upload a photo

""" STEP2_HTML = """

2 — What should it become?

""" 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'
' f'' f'' f'{i} — {label}
' ) elif i == active: rows += ( f'
' f'' f'{i} — {label}…
' ) else: rows += ( f'
' f'{i}' f'' f'{label}
' ) return ( f'
' f'

Working on it

' f'{rows}
' ) 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'
' f'{line.lstrip("-•123456789. ").strip()}
' for line in body.splitlines() if line.strip() and line.strip() not in ("-", "•") ) return ( f'
' f'
{icon} {title}
' f'{rows}
' ) return ( f'
' f'

{new_name}

' f'

' f'{tagline}

' + ( f'

' f'Identified: {html.escape(orig_name)} · {html.escape(orig_material)}

' if orig_name else "" ) + f'
' f'
' f'{phase_card("🔧", "Disassemble", phase1)}' f'{phase_card("🛒", "What you need", phase2)}' f'{phase_card("🔨", "Build it", phase3)}' f'
' ) 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 "
".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'
' f'' f'
' f'

{name}

' f'

→ {tname}

' f'

{tagline}

' f'
' ) cols_a = "".join(cards[0::3]) cols_b = "".join(cards[1::3]) cols_c = "".join(cards[2::3]) return ( f'
' f'

Recent ideas

' f'
' f'
{cols_a}
' f'
{cols_b}
' f'
{cols_c}
' f'
' ) def _home_hero_html() -> str: return ( '
' '

AI Upcycling

' '

' 'Give your things
a second life.

' '

' 'Upload any object you\'d throw away. Our AI identifies the parts, ' 'picks a transformation, and shows you how to build it.

' '
' '
' '
AI
' '
Vision model
' '
' '
' '
' '
5
' '
Categories
' '
' '
' '
' '
0%
' '
Waste
' '
' '
' '
' ) 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'
' f'' f'
' f'

{name}

' f'

→ {tname}

' f'

{tagline}

' f'
' ) if cards: cols_a = "".join(cards[0::3]) cols_b = "".join(cards[1::3]) cols_c = "".join(cards[2::3]) grid = ( f'
' f'
{cols_a}
' f'
{cols_b}
' f'
{cols_c}
' f'
' ) else: grid = ( '

No transformations yet. Be the first.

' ) return ( f'
' f'
' f'
' f'

Community transformations

' f'

Things people gave a second life

' f'
' f'{count} thing{"s" if count != 1 else ""}' f'
' f'
' f'' f'' f'' f'' f'' f'' f'
' f'{grid}' f'
' ) 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'
' f'

{icon} {title}

' f'
{body}
' f'
' ) if not (p1 or p2 or p3): return ( '

' 'No step-by-step guide available for this item.

' ) return ( f'
' + phase_block("🔧", "Disassemble", p1) + phase_block("🛒", "What you need", p2) + phase_block("🔨", "Build it", p3) + f'
' ) def load_exhibit_detail(exhibit_json: str): placeholder = ( '

Click any card to see the transformation.

' ) 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'
' f'

{html.escape(name)} →

' f'

{html.escape(tname)}

' f'

{html.escape(tagline)}

' f'
' ) 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'

' f'Vision error: {html.escape(str(e))}

', _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)