closet-twin / app.py
byeolunu's picture
Refactor item type handling for improved consistency in wardrobe rendering
74589b9
Raw
History Blame Contribute Delete
146 kB
import os
import sys
import json
import uuid
import datetime
from html import escape
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps
import gradio as gr
from huggingface_hub import InferenceClient
# ===== Gradio 6.18.0 compatibility fix for 'page' attribute errors =====
# The issue: Older Gradio versions' fill_expected_parents() tries to access child.page
# which doesn't exist on components. Fix by ensuring all Block instances have page attr.
# Patch Block.__init__ to initialize 'page' attribute
_original_block_init = gr.blocks.Block.__init__
def _patched_block_init(self, *args, **kwargs):
_original_block_init(self, *args, **kwargs)
# Ensure every block has a 'page' attribute
if not hasattr(self, 'page'):
self.page = None
gr.blocks.Block.__init__ = _patched_block_init
# Patch the instance method fill_expected_parents only if it exists in this Gradio version
_original_fill_expected = getattr(gr.blocks.Block, 'fill_expected_parents', None)
if _original_fill_expected is not None:
def _patched_fill_expected_parents(self):
"""Safely handle missing 'page' attribute"""
try:
for child in self.children:
if hasattr(child, 'page') and child.page is not None:
self.page = child.page
return
except Exception:
pass # Silently ignore errors in page propagation
gr.blocks.Block.fill_expected_parents = _patched_fill_expected_parents
# ===== End compatibility fix =====
# For Spaces compatibility - detect environment early
SPACE_ENV_VARS = ["SPACE_ID", "HUGGINGFACE_SPACE_ID", "HF_SPACE_ID"]
IS_SPACES = any(os.getenv(v) for v in SPACE_ENV_VARS)
FRONTEND_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(FRONTEND_DIR, "data")
IMAGES_DIR = os.path.join(FRONTEND_DIR, "images")
ORIGINAL_IMAGE_DIR = os.path.join(IMAGES_DIR, "original")
EDITED_IMAGE_DIR = os.path.join(IMAGES_DIR, "edited")
sys.path.append(FRONTEND_DIR)
def fs_path(path):
if not path:
return None
return path if os.path.isabs(path) else os.path.join(FRONTEND_DIR, path)
def fs_exists(path):
p = fs_path(path)
return os.path.exists(p) if p else False
def static_url(relative_path: str) -> str:
"""Return a Gradio-served URL for assets referenced from gr.HTML."""
rel = relative_path.replace("\\", "/").lstrip("/")
return f"/gradio_api/file={rel}"
def asset_url(url_or_path: str) -> str:
if not url_or_path:
return url_or_path
if url_or_path.startswith(("http://", "https://", "data:", "/gradio_api/file=", "/file=")):
return url_or_path
path = url_or_path.split("?", 1)[0]
url = static_url(path.lstrip("/"))
if "?" in url_or_path:
url = f"{url}?{url_or_path.split('?', 1)[1]}"
return url
IMG_FALLBACK = static_url("ui/image.png")
try:
from cards import process_image
from attributes import extract_attributes
from wardrobe import load_wardrobe, save_wardrobe, add_item
from vision_model import get_model, get_processor
import torch
HAS_LOCAL_MODELS = True
print("Loaded inference modules from small-hackathon.")
except Exception as _e:
HAS_LOCAL_MODELS = False
print("Warning – local models unavailable:", _e)
def load_wardrobe():
p = os.path.join(DATA_DIR, "wardrobe.json")
return json.load(open(p, "r", encoding="utf-8")) if os.path.exists(p) else []
def save_wardrobe(data):
with open(os.path.join(DATA_DIR, "wardrobe.json"), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def add_item(item):
w = load_wardrobe(); w.append(item); save_wardrobe(w)
def process_image(img):
raise RuntimeError("process_image unavailable")
def extract_attributes(img):
raise RuntimeError("extract_attributes unavailable")
# ── DB Setup ───────
GENERATED_CARD_DIR = os.path.join(FRONTEND_DIR, "static", "generated")
try:
for d in [DATA_DIR, ORIGINAL_IMAGE_DIR, EDITED_IMAGE_DIR, GENERATED_CARD_DIR]:
os.makedirs(d, exist_ok=True)
print(f"✓ Created directories: {DATA_DIR}")
except Exception as e:
print(f"✗ Directory creation failed: {e}")
PROFILE_PATH = os.path.join(DATA_DIR, "profile.json")
HISTORY_PATH = os.path.join(DATA_DIR, "history.json")
FAVORITES_PATH = os.path.join(DATA_DIR, "favorites.json")
PROFILE_BG_PATH = os.path.join(IMAGES_DIR, "profile_banner.jpg")
print(f"DEBUG: PROFILE_PATH = {PROFILE_PATH}")
print(f"DEBUG: HISTORY_PATH = {HISTORY_PATH}")
print(f"DEBUG: FAVORITES_PATH = {FAVORITES_PATH}")
def safe_init_json(path, default_data):
"""Safely initialize JSON file with default data."""
print(f"DEBUG: safe_init_json called for {path}")
try:
if not os.path.exists(path):
print(f"DEBUG: Creating {path}")
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"DEBUG: Directory created, writing JSON to {path}")
with open(path, "w", encoding="utf-8") as f:
json.dump(default_data, f, indent=2, ensure_ascii=False)
print(f"✓ Initialized {path}")
else:
print(f"DEBUG: {path} already exists, skipping")
except Exception as e:
print(f"✗ EXCEPTION in safe_init_json for {path}: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
print("DEBUG: About to call safe_init_json for PROFILE_PATH")
safe_init_json(PROFILE_PATH, {"name":"Sarah","bio":"Fashion lover who adores neutral tones and comfortable silhouettes.","twin_id":"0001"})
print("DEBUG: About to call safe_init_json for HISTORY_PATH")
safe_init_json(HISTORY_PATH, [])
print("DEBUG: About to call safe_init_json for FAVORITES_PATH")
safe_init_json(FAVORITES_PATH, [])
print("DEBUG: JSON initialization complete!")
def load_history():
return json.load(open(HISTORY_PATH,"r",encoding="utf-8")) if os.path.exists(HISTORY_PATH) else []
def save_history(h):
json.dump(h, open(HISTORY_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False)
def load_favorites():
return json.load(open(FAVORITES_PATH,"r",encoding="utf-8")) if os.path.exists(FAVORITES_PATH) else []
def save_favorites(f):
json.dump(f, open(FAVORITES_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False)
def load_profile():
return json.load(open(PROFILE_PATH,"r",encoding="utf-8"))
def save_profile(p):
json.dump(p, open(PROFILE_PATH,"w",encoding="utf-8"), indent=2, ensure_ascii=False)
# HuggingFace client
print("DEBUG: Reading HF_TOKEN environment variable")
HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACEHUB_API_TOKEN")
print(f"DEBUG: HF_TOKEN set: {bool(HF_TOKEN)}")
print("DEBUG: About to create InferenceClient")
try:
client = InferenceClient(api_key=HF_TOKEN)
print("DEBUG: InferenceClient created successfully")
except Exception as e:
print(f"ERROR creating InferenceClient: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
client = None
def get_style_profile():
history = load_history()
likes = [h for h in history if h.get("rating") == "like"]
if not likes:
return load_profile().get("bio","")
wm = {i["id"]:i for i in load_wardrobe()}
from collections import Counter
colors, fits, occasions = [], [], []
for l in likes:
occasions.append(l.get("situation",{}).get("occasion","casual"))
for iid in l.get("outfit",{}).get("items",{}).values():
if iid and iid in wm:
c = wm[iid].get("color",[])
colors += (c if isinstance(c,list) else [c])
f = wm[iid].get("fit","")
if f and f != "unknown": fits.append(f)
tc = [x[0] for x in Counter(colors).most_common(2)]
tf = [x[0] for x in Counter(fits).most_common(1)]
to = [x[0] for x in Counter(occasions).most_common(1)]
return f"Prefers {', '.join(tc) or 'neutral'} tones, {tf[0] if tf else 'comfortable'} fits, and {to[0] if to else 'casual'} occasions."
# ── HTML Builders ──
def img_src(item_id):
if fs_exists(f"images/edited/{item_id}_edited.png"):
return static_url(f"images/edited/{item_id}_edited.png")
return static_url(f"images/original/{item_id}.png")
def normalize_item_type(value, default="item"):
if isinstance(value, list):
return ", ".join(str(v) for v in value if v)
if value is None:
return default
text = str(value).strip()
if (text.startswith("['") and text.endswith("']")) or (text.startswith('["') and text.endswith('"]')):
text = text[2:-2]
return text.strip("[] ") or default
def render_wardrobe_html(cat="all", selected_id=None):
items = load_wardrobe()
if cat != "all": items = [i for i in items if i.get("category")==cat]
if not items:
return "<div class='ct-empty'>Your wardrobe is empty. Upload your first garment to get started.</div>"
html = '<div class="ct-grid">'
def _clean(v):
if isinstance(v, list): return v[0] if v else ""
return str(v).replace("['", "").replace("']", "").replace('["', '').replace('"]', '').strip("[]")
for item in items:
col = ", ".join(item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","—")])
selected = item['id'] == selected_id
badge = "<div class='ct-selected-pill'>selected</div>" if selected else ""
highlight = "box-shadow: 0 0 0 2px rgba(179,116,128,.3); border-color: var(--ct-pink);" if selected else ""
html += f"""
<div class="ct-polaroid" style="{highlight}">
<div class="ct-clip"></div>
{badge}
<div class="ct-photo"><img src="{img_src(item['id'])}" onerror="this.src='{IMG_FALLBACK}'" alt=""></div>
<div class="ct-caption">{normalize_item_type(item.get('type','item'))}</div>
<div class="ct-meta">{col} · {_clean(item.get('style','—'))} · {_clean(item.get('fit','—'))}</div>
</div>"""
return html + "</div>"
def format_item_choice(item):
cols = item.get("color", [])
if not isinstance(cols, list):
cols = [cols] if cols else []
color_label = ", ".join([c for c in cols if c]) or "unknown"
return f"{normalize_item_type(item.get('type','item'))} ({color_label})"
def item_choices(cat="all"):
items = load_wardrobe()
if cat != "all":
items = [i for i in items if i.get("category") == cat]
return [(format_item_choice(i), i['id']) for i in items]
def item_has_image(item):
item_id = item.get("id")
image_clean = item.get("image_clean") or ""
image_original = item.get("image_original") or ""
return (
fs_exists(image_clean)
or fs_exists(image_original)
or fs_exists(f"images/edited/{item_id}_edited.png")
or fs_exists(f"images/original/{item_id}.png")
)
def item_kind(item):
category = str(item.get("category", "")).lower()
item_type = item.get("type", "")
if isinstance(item_type, list):
item_type = " ".join(str(t) for t in item_type)
item_type = str(item_type).lower()
if category == "dress" or "dress" in item_type:
return "dress"
return category
def slot_for_item(item):
kind = item_kind(item)
if kind in {"top", "bottom", "dress", "shoes", "outerwear", "accessory"}:
return kind
return None
def describe_item(item):
item_type = item.get("type", item.get("category", "item"))
if isinstance(item_type, list):
item_type = ", ".join(str(t) for t in item_type)
colors = item.get("color", [])
if not isinstance(colors, list):
colors = [colors] if colors else []
color_text = ", ".join(str(c) for c in colors if c) or "neutral"
pattern = item.get("pattern", "solid")
style = item.get("style", "simple")
type_lower = str(item_type).lower()
if any(str(c).lower() in type_lower for c in colors if c):
color_text = ""
color_prefix = f"{color_text} " if color_text else ""
return f"{color_prefix}{item_type}, {pattern}, {style}"
def build_flux_prompt(outfit, wm):
item_descriptions = []
for slot, item_id in outfit.get("items", {}).items():
if item_id and item_id in wm:
item_descriptions.append(f"{slot}: {describe_item(wm[item_id])}")
energy = outfit.get("energy_detected", "effortless, wearable, real closet")
styling = outfit.get("styling_note", "simple relaxed styling")
return (
"Pinterest-style outfit layout card, no model body, flat lay composition, "
"clean editorial wardrobe collage using only these real owned closet pieces: "
+ "; ".join(item_descriptions)
+ f". Energy: {energy}. Styling note: {styling}. "
"Soft natural light, cohesive color palette, tasteful spacing, magazine layout, "
"no shopping labels, no prices, no extra clothing items, no body-shaming text."
)
def selected_item_descriptions(items, wm):
descriptions = []
for slot in ["dress", "top", "bottom", "outerwear", "shoes", "accessory"]:
item_id = items.get(slot)
if item_id and item_id in wm:
descriptions.append((slot, describe_item(wm[item_id])))
return descriptions
def build_clean_reasoning(outfit, wm, adjusted=False):
descriptions = selected_item_descriptions(outfit.get("items", {}), wm)
if descriptions:
pieces = ", ".join(desc for _slot, desc in descriptions)
else:
pieces = "the selected real closet pieces"
energy = outfit.get("energy_detected", "wearable, closet-based")
reason = (
f"This keeps the {energy} energy using only these saved closet items: {pieces}. "
"The pieces are balanced as one wearable decision, without adding anything new."
)
if adjusted:
reason += " No-buy check adjusted the outfit to remove invented or conflicting pieces."
return reason
def build_clean_styling_note(outfit, wm):
items = outfit.get("items", {})
if items.get("dress"):
return "Let the dress be the main piece; keep accessories simple so it still feels easy."
if items.get("outerwear"):
return "Wear the outer layer open for a relaxed, put-together shape."
if items.get("top") and items.get("bottom"):
return "Keep the top relaxed with the bottom sitting naturally at the waist."
return "Keep the styling simple and intentional."
def refresh_outfit(outfit, wm, adjusted=False):
refreshed = {
"name": outfit.get("name") or "Closet Twin outfit",
"items": {slot: outfit.get("items", {}).get(slot) for slot in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]},
"energy_detected": outfit.get("energy_detected", "wearable, closet-based"),
}
if refreshed["items"].get("dress"):
refreshed["items"]["top"] = None
refreshed["items"]["bottom"] = None
refreshed["reasoning"] = build_clean_reasoning(refreshed, wm, adjusted)
refreshed["styling_note"] = build_clean_styling_note(refreshed, wm)
refreshed["card_prompt"] = build_flux_prompt(refreshed, wm)
refreshed["flux_prompt"] = refreshed["card_prompt"]
return refreshed
def text_terms(value):
if isinstance(value, list):
value = " ".join(str(v) for v in value)
text = str(value or "").lower()
for char in ["|", ",", "/", "(", ")", ".", ":", ";"]:
text = text.replace(char, " ")
text = text.replace("-", " ")
return set(text.split())
def item_search_text(item):
fields = [
item.get("category", ""),
item.get("type", ""),
item.get("color", ""),
item.get("pattern", ""),
item.get("style", ""),
item.get("fit", ""),
item.get("season", ""),
item.get("description", ""),
]
return " ".join(" ".join(v) if isinstance(v, list) else str(v) for v in fields).lower()
COLOR_WORDS = [
"black", "white", "cream", "beige", "brown", "gray", "grey", "blue",
"green", "olive", "sage", "khaki", "yellow", "pink", "red", "navy",
"pastel", "silver", "metallic", "champagne",
]
STYLE_WORDS = [
"casual", "romantic", "tailored", "polished", "soft", "minimal",
"bohemian", "sporty", "elegant", "formal", "vintage", "cozy",
"effortless", "structured", "relaxed", "feminine",
]
COLOR_FAMILIES = [
{"green", "olive", "sage", "khaki"},
{"beige", "cream", "champagne", "khaki", "tan"},
{"brown", "dark brown", "tan"},
{"gray", "grey", "silver", "metallic"},
{"blue", "light blue", "navy"},
{"black"},
{"white", "cream"},
]
def colors_are_related(ref_color, item_color):
ref_color = str(ref_color or "").lower()
item_color = str(item_color or "").lower()
if not ref_color or not item_color:
return False
if ref_color in item_color or item_color in ref_color:
return True
return any(ref_color in family and item_color in family for family in COLOR_FAMILIES)
def classify_reference_pixel(r, g, b):
maxc, minc = max(r, g, b), min(r, g, b)
if maxc > 242 and (maxc - minc) < 24:
return None
if maxc < 55:
return "black"
if maxc > 170 and (maxc - minc) < 30:
return "silver"
if r > 95 and g > 72 and b < 65 and r >= g:
return "brown"
if g >= r - 8 and g > b + 12:
if maxc < 155:
return "olive"
return "sage"
if r > 165 and g > 145 and b > 105:
return "beige"
if b > r + 18 and b > g + 10:
return "blue"
if r > 160 and b > 120 and g < 150:
return "pink"
return None
def analyze_reference_image_fallback(img, note=""):
small = img.convert("RGB")
small.thumbnail((96, 96))
counts = {}
for r, g, b in small.getdata():
color = classify_reference_pixel(r, g, b)
if color:
counts[color] = counts.get(color, 0) + 1
colors = [name for name, _count in sorted(counts.items(), key=lambda pair: pair[1], reverse=True)[:5]]
note_text = str(note or "").lower()
slots = []
if "dress" in note_text:
slots.append("dress")
else:
if any(word in note_text for word in ["top", "tank", "shirt", "tee", "blouse", "cami"]):
slots.append("top")
if any(word in note_text for word in ["pants", "trouser", "jeans", "skirt", "shorts"]):
slots.append("bottom")
if any(word in note_text for word in ["sandal", "shoe", "sneaker", "flip"]):
slots.append("shoes")
if any(word in note_text for word in ["watch", "bag", "jewelry", "accessory", "silver"]):
slots.append("accessory")
if any(word in note_text for word in ["blazer", "jacket", "coat", "cardigan", "outerwear"]):
slots.append("outerwear")
if not slots:
slots = ["top", "bottom", "shoes", "accessory"]
description_bits = []
if colors:
description_bits.append(", ".join(colors) + " tones")
description_bits.append(note or "flat-lay reference outfit")
return {
"description": "reference outfit with " + " and ".join(description_bits),
"style": note or "minimal relaxed casual effortless",
"color": colors,
"slots": list(dict.fromkeys(slots)),
}
def normalize_reference_details(details=None, note=""):
details = details or {}
note_text = str(note or "").lower()
colors = details.get("color", [])
if not isinstance(colors, list):
colors = [colors] if colors else []
colors = [str(c).lower() for c in colors if c]
colors += [c for c in COLOR_WORDS if c in note_text and c not in colors]
style = details.get("style", "")
style_terms = {term for term in text_terms(style) if term in STYLE_WORDS}
style_terms |= {s for s in STYLE_WORDS if s in note_text}
category = str(details.get("category", "") or "").lower()
if "dress" in note_text:
category = "dress"
elif any(word in note_text for word in ["pants", "jeans", "skirt", "shorts"]):
category = "bottom"
elif any(word in note_text for word in ["blazer", "jacket", "coat"]):
category = "outerwear"
elif any(word in note_text for word in ["top", "blouse", "shirt", "tee", "cami"]):
category = "top"
slots = details.get("slots", [])
if not isinstance(slots, list):
slots = [slots] if slots else []
slots = [str(slot).lower() for slot in slots if slot]
if "dress" in note_text and "dress" not in slots:
slots.append("dress")
if any(word in note_text for word in ["top", "tank", "blouse", "shirt", "tee", "cami"]) and "top" not in slots:
slots.append("top")
if any(word in note_text for word in ["pants", "trouser", "jeans", "skirt", "shorts"]) and "bottom" not in slots:
slots.append("bottom")
if any(word in note_text for word in ["sandal", "shoe", "sneaker", "flip"]) and "shoes" not in slots:
slots.append("shoes")
if any(word in note_text for word in ["watch", "bag", "jewelry", "accessory", "silver"]) and "accessory" not in slots:
slots.append("accessory")
if any(word in note_text for word in ["blazer", "jacket", "coat", "cardigan", "outerwear"]) and "outerwear" not in slots:
slots.append("outerwear")
ref_type = details.get("type", "")
if isinstance(ref_type, list):
ref_type = " ".join(str(t) for t in ref_type)
ref_text = " ".join([
str(details.get("description", "")),
str(ref_type),
str(style),
note_text,
]).lower()
return {
"category": category,
"type": str(ref_type or "").lower(),
"colors": list(dict.fromkeys(colors)),
"style_terms": style_terms,
"text": ref_text,
"description": details.get("description") or note or "reference look",
"slots": list(dict.fromkeys(slots)),
}
def score_item_against_reference(item, ref, preferred_slot=None):
score = 0
kind = slot_for_item(item)
if preferred_slot and kind == preferred_slot:
score += 5
if ref.get("category") and kind == ref.get("category"):
score += 4
item_text = item_search_text(item)
item_colors = [str(c).lower() for c in (item.get("color", []) if isinstance(item.get("color"), list) else [item.get("color", "")])]
score += 3 * sum(
1 for color in ref.get("colors", [])
if any(colors_are_related(color, item_color) for item_color in item_colors) or color in item_text
)
score += 2 * len(text_terms(item.get("style", "")) & ref.get("style_terms", set()))
ref_terms = text_terms(ref.get("text", ""))
item_terms = text_terms(item_search_text(item))
score += min(6, len(item_terms & ref_terms))
if kind == "top" and ref_terms & {"tank", "cami", "camisole"}:
score += 4 if item_terms & {"tank", "cami", "camisole", "sleeveless"} else -2
if kind == "bottom" and ref_terms & {"pants", "trouser", "trousers", "wide", "leg"}:
score += 5 if item_terms & {"pants", "trouser", "trousers", "wide", "leg", "jeans"} else -3
if item_terms & {"skirt", "shorts"}:
score -= 4
if kind == "bottom" and "skirt" in ref_terms:
score += 4 if "skirt" in item_terms else -2
if kind == "shoes" and ref_terms & {"sandal", "sandals", "flip", "slides"}:
score += 4 if item_terms & {"sandal", "sandals", "slide", "slides", "flip"} else -1
if kind == "accessory" and ref_terms & {"watch", "silver", "jewelry"}:
score += 4 if item_terms & {"watch", "silver", "jewelry", "metallic"} else -1
if item_has_image(item):
score += 2
return score
def best_items_for_slot(wardrobe, ref, slot, used=None, limit=3):
used = used or set()
candidates = []
for item in wardrobe:
if item.get("id") in used or not item_has_image(item):
continue
if slot_for_item(item) != slot:
continue
candidates.append((score_item_against_reference(item, ref, slot), item))
candidates.sort(key=lambda pair: (-pair[0], pair[1].get("created_at", "")))
return [item for score, item in candidates[:limit] if score > 0] or [item for _score, item in candidates[:limit]]
def build_recreated_outfit(wardrobe, ref, variation_idx=0):
used = set()
items = {slot: None for slot in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]}
requested_slots = set(ref.get("slots") or ["top", "bottom", "shoes", "accessory"])
def get_match(slot):
matches = best_items_for_slot(wardrobe, ref, slot, used, limit=variation_idx + 1)
return matches[variation_idx] if len(matches) > variation_idx else (matches[-1] if matches else None)
prefer_dress = "dress" in requested_slots or ref.get("category") == "dress" or "dress" in ref.get("text", "")
if prefer_dress:
dress = get_match("dress")
if dress:
items["dress"] = dress["id"]
used.add(dress["id"])
if not items["dress"]:
for slot in ["top", "bottom"]:
if slot not in requested_slots:
continue
match = get_match(slot)
if match:
items[slot] = match["id"]
used.add(match["id"])
for slot in ["outerwear", "shoes", "accessory"]:
if slot not in requested_slots:
continue
match = get_match(slot)
if match:
items[slot] = match["id"]
used.add(match["id"])
outfit = {
"name": "Recreated closet look",
"items": items,
"energy_detected": ", ".join(sorted(ref.get("style_terms") or [])) or "same energy, real closet",
}
return refresh_outfit(outfit, {i["id"]: i for i in wardrobe}, adjusted=False)
def closet_gap_notes(outfit, ref, wm):
ref_text = ref.get("text", "")
notes = []
bottom = wm.get(outfit.get("items", {}).get("bottom"))
shoes = wm.get(outfit.get("items", {}).get("shoes"))
accessory = wm.get(outfit.get("items", {}).get("accessory"))
if any(word in ref_text for word in ["sage", "khaki", "olive"]) and bottom:
bottom_colors = bottom.get("color", [])
if not isinstance(bottom_colors, list):
bottom_colors = [bottom_colors]
if not any(colors_are_related(color, c) for color in ["sage", "khaki", "olive"] for c in bottom_colors):
notes.append(f"No sage/olive wide-leg bottom is saved, so I used the closest relaxed bottom: {bottom.get('type', 'bottom')}.")
if any(word in ref_text for word in ["sandal", "sandals", "flip", "slides"]) and shoes:
shoe_text = item_search_text(shoes)
if not any(word in shoe_text for word in ["sandal", "sandals", "flip", "slides"]):
notes.append(f"No brown sandals are saved, so I used the available shoes: {shoes.get('type', 'shoes')}.")
if any(word in ref_text for word in ["watch", "silver", "jewelry"]) and accessory:
accessory_text = item_search_text(accessory)
if not any(word in accessory_text for word in ["watch", "silver", "metallic", "jewelry"]):
notes.append(f"No silver watch is saved, so I used the closest simple accessory: {accessory.get('type', 'accessory')}.")
if not notes:
return ""
return "<div class='ct-reasoning ct-reasoning-pink' style='margin-top:.85rem'><strong>Closest closet match:</strong><br>" + "<br>".join(escape(note) for note in notes) + "</div>"
def item_image_path(item_id, wm=None):
item = (wm or {}).get(item_id, {})
candidates = [
item.get("image_clean") or "",
item.get("image_original") or "",
f"images/edited/{item_id}_edited.png",
f"images/original/{item_id}.png",
]
for path in candidates:
if path and fs_exists(path):
return fs_path(path)
return None
def load_card_font(size, bold=False):
font_names = [
"C:/Windows/Fonts/georgiab.ttf" if bold else "C:/Windows/Fonts/georgia.ttf",
"C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf",
]
for font_name in font_names:
try:
return ImageFont.truetype(font_name, size)
except Exception:
pass
return ImageFont.load_default()
def draw_wrapped(draw, text, xy, font, fill, max_width, line_gap=6):
words = str(text or "").split()
if not words:
return xy[1]
x, y = xy
line = ""
for word in words:
trial = (line + " " + word).strip()
bbox = draw.textbbox((x, y), trial, font=font)
if bbox[2] - bbox[0] <= max_width or not line:
line = trial
else:
draw.text((x, y), line, font=font, fill=fill)
y += (bbox[3] - bbox[1]) + line_gap
line = word
if line:
draw.text((x, y), line, font=font, fill=fill)
bbox = draw.textbbox((x, y), line, font=font)
y += (bbox[3] - bbox[1]) + line_gap
return y
def make_product_cutout(image_path):
img = Image.open(image_path).convert("RGBA")
rgb = img.convert("RGB")
corners = [
rgb.getpixel((0, 0)),
rgb.getpixel((rgb.width - 1, 0)),
rgb.getpixel((0, rgb.height - 1)),
rgb.getpixel((rgb.width - 1, rgb.height - 1)),
]
bg = tuple(sum(c[i] for c in corners) // len(corners) for i in range(3))
def is_background(px):
r, g, b, a = px
if a == 0:
return True
dist = ((r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2) ** 0.5
maxc, minc = max(r, g, b), min(r, g, b)
return dist < 44 or (maxc > 242 and (maxc - minc) < 22)
pix = img.load()
width, height = img.size
stack = []
seen = set()
for x in range(width):
stack.append((x, 0))
stack.append((x, height - 1))
for y in range(height):
stack.append((0, y))
stack.append((width - 1, y))
while stack:
x, y = stack.pop()
if (x, y) in seen or x < 0 or y < 0 or x >= width or y >= height:
continue
seen.add((x, y))
if not is_background(pix[x, y]):
continue
r, g, b, _a = pix[x, y]
pix[x, y] = (r, g, b, 0)
stack.extend(((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)))
bbox = img.getbbox()
return img.crop(bbox) if bbox else img
def paste_flatlay_item(canvas, image_path, center, max_size, angle=0, shadow=True):
img = make_product_cutout(image_path)
img = ImageOps.contain(img, max_size)
if angle:
img = img.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
x = int(center[0] - img.width / 2)
y = int(center[1] - img.height / 2)
if shadow:
alpha = img.getchannel("A")
shadow_img = Image.new("RGBA", img.size, (0, 0, 0, 34))
shadow_img.putalpha(alpha.filter(ImageFilter.GaussianBlur(7)))
canvas.alpha_composite(shadow_img, (x + 10, y + 12))
canvas.alpha_composite(img, (x, y))
def create_outfit_collage(outfit, wm):
items = outfit.get("items", {})
paths = {
slot: item_image_path(item_id, wm) if item_id else None
for slot, item_id in items.items()
}
paths = {slot: path for slot, path in paths.items() if path}
if not paths:
raise ValueError("No selected outfit items have usable images.")
width, height = 900, 1200
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
if paths.get("dress"):
paste_flatlay_item(canvas, paths["dress"], (520, 500), (390, 900), shadow=True)
if paths.get("outerwear"):
paste_flatlay_item(canvas, paths["outerwear"], (610, 315), (330, 420), angle=2, shadow=True)
else:
if paths.get("outerwear"):
paste_flatlay_item(canvas, paths["outerwear"], (575, 250), (340, 430), angle=2, shadow=True)
if paths.get("top"):
paste_flatlay_item(canvas, paths["top"], (530, 250), (360, 430), shadow=True)
if paths.get("bottom"):
paste_flatlay_item(canvas, paths["bottom"], (540, 645), (430, 780), shadow=True)
if paths.get("shoes"):
paste_flatlay_item(canvas, paths["shoes"], (260, 875), (330, 360), angle=-14, shadow=True)
if paths.get("accessory"):
paste_flatlay_item(canvas, paths["accessory"], (250, 450), (280, 340), angle=-4, shadow=True)
card_id = str(uuid.uuid4())
card_path = os.path.join(GENERATED_CARD_DIR, f"{card_id}.png")
canvas.convert("RGB").save(card_path, quality=95)
return card_path, static_url(f"static/generated/{card_id}.png"), len(paths)
def sanitize_outfits(outfits, wardrobe, anchor=None):
wm = {item["id"]: item for item in wardrobe}
clean_outfits = []
allowed_slots = ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]
for idx, outfit in enumerate(outfits[:3]):
raw_items = outfit.get("items", {}) if isinstance(outfit, dict) else {}
clean_items = {slot: None for slot in allowed_slots}
changed = False
for slot, item_id in raw_items.items():
if item_id in [None, "", "null"]:
continue
if item_id not in wm:
changed = True
continue
item = wm[item_id]
correct_slot = slot_for_item(item)
if correct_slot is None:
changed = True
continue
if correct_slot != slot:
changed = True
if correct_slot == "dress":
clean_items["dress"] = item_id
clean_items["top"] = None
clean_items["bottom"] = None
elif clean_items.get(correct_slot) is None:
clean_items[correct_slot] = item_id
if clean_items.get("dress"):
clean_items["top"] = None
clean_items["bottom"] = None
if anchor and anchor in wm and anchor not in clean_items.values():
anchor_slot = slot_for_item(wm[anchor])
if anchor_slot:
clean_items[anchor_slot] = anchor
if item_kind(wm[anchor]) == "dress":
clean_items["top"] = None
clean_items["bottom"] = None
changed = True
if clean_items.get("dress"):
clean_items["top"] = None
clean_items["bottom"] = None
if not any(clean_items.values()):
# LLM hallucinated IDs – build a fallback outfit from real wardrobe items
slots_order = ["top", "bottom", "shoes", "accessory", "outerwear", "dress"]
for slot in slots_order:
candidates = [item for item in wardrobe
if slot_for_item(item) == slot and item_has_image(item)
and item["id"] not in clean_items.values()]
if candidates:
best = sorted(candidates, key=lambda i: i.get("created_at",""), reverse=True)[0]
clean_items[slot] = best["id"]
break
if not any(clean_items.values()):
continue
clean_outfit = {
"name": outfit.get("name") or f"Outfit {idx + 1}",
"items": clean_items,
"energy_detected": outfit.get("energy_detected", "wearable, closet-based"),
}
clean_outfit["reasoning"] = build_clean_reasoning(clean_outfit, wm, changed)
clean_outfit["styling_note"] = build_clean_styling_note(clean_outfit, wm)
clean_outfit["card_prompt"] = build_flux_prompt(clean_outfit, wm)
clean_outfit["flux_prompt"] = clean_outfit["card_prompt"]
clean_outfits.append(clean_outfit)
return clean_outfits
def ensure_three_outfits(outfits, wardrobe, anchor=None):
wm = {item["id"]: item for item in wardrobe}
slots = ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]
results = list(outfits or [])[:3]
signatures = {
tuple((outfit.get("items") or {}).get(slot) for slot in slots)
for outfit in results
}
pools = {
slot: [item for item in wardrobe if slot_for_item(item) == slot and item_has_image(item)]
for slot in slots
}
fallback_names = ["Safest wearable option", "Softer option", "More polished option"]
fallback_energy = [
"easy, wearable, real closet",
"soft, relaxed, closet-based",
"polished, intentional, no-buy",
]
def build_candidate(offset):
items = {slot: None for slot in slots}
if anchor and anchor in wm:
anchor_slot = slot_for_item(wm[anchor])
if anchor_slot:
items[anchor_slot] = anchor
if items.get("dress"):
fill_slots = ["shoes", "accessory", "outerwear"]
else:
fill_slots = ["top", "bottom", "shoes", "accessory", "outerwear"]
for slot in fill_slots:
if items.get(slot):
continue
choices = [item for item in pools.get(slot, []) if item["id"] not in items.values()]
if choices:
items[slot] = choices[offset % len(choices)]["id"]
if not (items.get("dress") or items.get("top") or items.get("bottom")):
choices = [item for item in pools.get("dress", []) if item["id"] not in items.values()]
if choices:
items["dress"] = choices[offset % len(choices)]["id"]
items["top"] = None
items["bottom"] = None
if not any(items.values()):
return None
idx = len(results) % 3
outfit = {
"name": fallback_names[idx],
"items": items,
"energy_detected": fallback_energy[idx],
}
return refresh_outfit(outfit, wm, adjusted=True)
offset = 0
while len(results) < 3 and offset < max(12, len(wardrobe) * 3):
candidate = build_candidate(offset)
offset += 1
if not candidate:
continue
signature = tuple(candidate.get("items", {}).get(slot) for slot in slots)
if signature in signatures and len(wardrobe) > 1:
continue
signatures.add(signature)
results.append(candidate)
while results and len(results) < 3:
idx = len(results) % len(results)
clone = json.loads(json.dumps(results[idx]))
label_idx = len(results) % 3
clone["name"] = fallback_names[label_idx]
clone["energy_detected"] = fallback_energy[label_idx]
results.append(refresh_outfit(clone, wm, adjusted=True))
return results[:3]
def favorite_choices():
return [(fav.get('name','Saved Look'), fav.get('id')) for fav in load_favorites()]
def parse_item_id(selection):
if not selection:
return None
# Support backward compatibility if someone had an old string saved
if " — " in selection:
return selection.split(" — ", 1)[0]
return selection
def render_saved_cards_html(active_idx=0):
cards = [fav for fav in load_favorites() if fav.get("card_url")]
if not cards:
return "<div class='ct-empty' style='text-align:center;padding:2rem'>No outfit cards saved yet. Generate a card from the stylist page, then save it here.</div>"
total = len(cards)
slides_html = ""
for i, fav in enumerate(reversed(cards)):
name = escape(fav.get("name") or "Saved outfit card")
energy = escape(fav.get("energy_detected") or "closet-based")
styling = escape(fav.get("styling_note") or "")
url = asset_url(fav.get("card_url"))
saved_at = escape(fav.get("card_saved_at") or fav.get("saved_at") or "")
item_count = len([item_id for item_id in fav.get("items", {}).values() if item_id])
active = "block" if i == int(active_idx or 0) else "none"
slides_html += f"""
<div class="ct-sc-slide" id="ct-sc-slide-{i}" style="display:{active}">
<div class="ct-sc-img-wrap">
<img src="{url}" alt="{name}" class="ct-sc-img">
<span class="ct-sc-badge">{item_count} pieces</span>
</div>
<div class="ct-sc-info">
<div class="ct-sc-name">{name}</div>
<div class="ct-sc-energy">{energy}</div>
{f'<div class="ct-sc-note">{styling}</div>' if styling else ''}
<div class="ct-sc-meta">{saved_at}</div>
<a class="ct-sc-dl-btn" href="{url}" download>Download Card</a>
</div>
</div>"""
uid = "ct_saved_carousel"
return f"""
<div class="ct-sc-root" id="{uid}">
<div class="ct-sc-header">
<span class="ct-sc-title">outfit cards</span>
<span class="ct-sc-counter" id="{uid}_ctr">{int(active_idx or 0) + 1} / {total}</span>
</div>
<div class="ct-sc-body">{slides_html}</div>
</div>"""
def render_outfits_html(outfits, wm, active_idx=0):
if not outfits:
return """
<div class='ct-stylist-empty'>
<div class='ct-empty-title'>What are we dressing for?</div>
<p>Describe the vibe or upload an inspiration look. Closet Twin will choose from saved wardrobe pieces only.</p>
</div>"""
active_idx = max(0, min(active_idx, len(outfits) - 1))
cards = []
for idx, o in enumerate(outfits):
display = 'block' if idx == active_idx else 'none'
items_html = ""
chips_html = ""
for slot, iid in o.get("items", {}).items():
if iid and iid in wm:
wi = wm[iid]
items_html += f"""
<div class="ct-result-piece">
<img src="{img_src(iid)}" onerror="this.src='{IMG_FALLBACK}'" alt="">
<div>
<strong>{escape(normalize_item_type(wi.get('type', slot)))}</strong>
<span>{escape(str(slot))}</span>
</div>
</div>"""
chips_html += f"""
<span class="ct-garment-chip">{escape(normalize_item_type(wi.get('type', slot)))}
<small>{escape(str(slot))}</small>
</span>"""
cards.append(f"""
<div class="ct-outfit-slide" style="display: {display};" data-slide-index="{idx}">
<div class="ct-wear-result">
<div class="ct-wear-kicker">energy detected: {escape(str(o.get('energy_detected','wearable, closet-based')))}</div>
<div class="ct-wear-title">wear this</div>
<div class="ct-wear-layout">
<div class="ct-wear-left">
<div class="ct-result-piece-grid">{items_html}</div>
<div class="ct-look-caption">"{escape(str(o.get('name','Closet Twin look'))).lower()}"</div>
</div>
<div class="ct-wear-right">
<div class="ct-side-label">garments</div>
<div class="ct-garment-chip-grid">{chips_html}</div>
<div class="ct-reason-panel">
<div class="ct-side-label">why this works</div>
<p>{escape(str(o.get('reasoning','')))}</p>
<div class="ct-panel-divider"></div>
<div class="ct-side-label">styling note</div>
<p><em>{escape(str(o.get('styling_note','Keep the styling simple and intentional.')))}</em></p>
</div>
</div>
</div>
</div>
</div>"""
)
return f"""
<div class='ct-interactive-carousel'>
<div class='ct-carousel-slides'>
{''.join(cards)}
</div>
</div>"""
def render_favorites_html():
favs = load_favorites(); wm = {i["id"]:i for i in load_wardrobe()}
if not favs: return "<div class='ct-empty'>No saved outfits yet.</div>"
html = ""
for fav in favs:
items_html = "".join(f"""
<div class="ct-polaroid" style="max-width:100px">
<div class="ct-photo" style="height:90px"><img src="{img_src(iid)}" onerror="this.src='{IMG_FALLBACK}'" alt=""></div>
<div class="ct-caption" style="font-size:0.85rem">{normalize_item_type(wm[iid].get('type',slot)) if iid in wm else slot}</div>
</div>""" for slot,iid in fav.get("items",{}).items() if iid)
html += f"""
<div class="ct-card ct-card-dashed" style="margin-bottom:1.5rem">
<div class="ct-card-header">
<span class="ct-card-title">{fav.get('name','Saved Look')}</span>
<span style="font-size:.75rem;color:var(--ct-taupe)">{fav.get('saved_at','')}</span>
</div>
<div class="ct-grid" style="margin-top:.75rem">{items_html}</div>
<div class="ct-reasoning ct-reasoning-pink">{fav.get('reasoning','')}</div>
</div>"""
return html
def render_history_html():
history = load_history()
if not history: return "<div class='ct-empty'>No styling history yet.</div>"
html = "<div class='ct-history'>"
for idx, h in enumerate(reversed(history)):
rating = h.get("rating","generated")
col = "#315F9D" if rating=="like" else "#930500" if rating=="dislike" else "#7d2b27"
label_icon = "↻" if rating=="like" else "x" if rating=="dislike" else "+"
detail_id = f"ct-hist-detail-{idx}"
html += f"""
<details class="ct-history-accordion">
<summary class="ct-history-summary">
<div class="ct-history-summary-inner">
<div>
<strong>{h.get('timestamp','')[:10]}</strong>
<span class="ct-hist-occasion"> — {h.get('situation',{}).get('occasion','Casual')}</span>
</div>
<span class="ct-hist-badge" style="color:{col};">{label_icon} {rating}</span>
</div>
</summary>
<div class="ct-history-body">
<span style="color:var(--ct-taupe);font-style:italic;">{h.get('outfit',{}).get('name','Outfit')}</span>
<div style="margin-top:.35rem;font-size:.78rem;color:var(--ct-ltaupe);">Weather: {h.get('situation',{}).get('weather','—')} · Mood: {h.get('situation',{}).get('mood','—')}</div>
</div>
</details>"""
return html + "</div>"
def render_analytics_html():
wardrobe = load_wardrobe(); history = load_history()
total = len(wardrobe)
cats, cols = {}, {}
for item in wardrobe:
cats[item.get("category","?")] = cats.get(item.get("category","?"),0)+1
for c in (item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")]):
cols[c] = cols.get(c,0)+1
used = set()
for h in history:
if h.get("rating")=="like":
for iid in h.get("outfit",{}).get("items",{}).values():
if iid: used.add(iid)
underused = [i for i in wardrobe if i["id"] not in used]
def bars(d, color, total_ref):
if not d: return "<p class='ct-empty-sm'>No data yet.</p>"
return "".join(f"""
<div style="margin-bottom:.6rem">
<div style="display:flex;justify-content:space-between;font-size:.82rem;margin-bottom:2px">
<span style="text-transform:capitalize">{k}</span><span>{v}</span>
</div>
<div style="height:7px;background:#eadfc7;border-radius:4px;overflow:hidden">
<div style="width:{int(v/total_ref*100) if total_ref else 0}%;height:100%;background:{color};border-radius:4px"></div>
</div>
</div>""" for k,v in sorted(d.items(),key=lambda x:-x[1])[:6])
underused_html = "".join(f"""
<div style="flex:0 0 72px;text-align:center;font-size:.72rem">
<img src="{img_src(i['id'])}" onerror="this.src='{IMG_FALLBACK}'" style="width:72px;height:72px;object-fit:cover;border-radius:4px;border:1px solid #eadfc7">
<div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:3px">{normalize_item_type(i.get('type','item'))}</div>
</div>""" for i in underused[:6]) or "<p class='ct-empty-sm'>Every item has been worn!</p>"
return f"""
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div class="ct-card"><div class="ct-card-title">Closet by Category</div>{bars(cats,'#930500',total)}</div>
<div class="ct-card"><div class="ct-card-title">Dominant Colors</div>{bars(cols,'#C9DAEA',total)}</div>
</div>
<div class="ct-card" style="margin-top:1.5rem">
<div class="ct-card-title">Rarely Worn Items</div>
<p style="font-size:.82rem;color:var(--ct-taupe);margin-bottom:.75rem">These garments haven't appeared in a liked outfit yet — try building a look around them.</p>
<div style="display:flex;gap:.75rem;overflow-x:auto;padding-bottom:.5rem">{underused_html}</div>
</div>"""
def render_badge_html():
p = load_profile()
t = int(datetime.datetime.now().timestamp())
avatar = f"{static_url('images/profile_avatar.png')}?t={t}" if fs_exists("images/profile_avatar.png") else "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='50' fill='%23F7F0E6'/><path d='M50 30a15 15 0 1 0 0 30 15 15 0 0 0 0-30zm0 35c-20 0-35 10-35 20v5h70v-5c0-10-15-20-35-20z' fill='%23930500'/></svg>"
banner_style = f"background-image:url('{static_url('images/profile_banner.jpg')}?t={t}');background-size:cover;background-position:center;" if fs_exists(PROFILE_BG_PATH) else "background-color:#FDFBFC;background-image:linear-gradient(rgba(147,5,0,.08) 1px, transparent 1px),linear-gradient(90deg, rgba(147,5,0,.08) 1px, transparent 1px);background-size:32px 32px;background-position:center;"
wardrobe = load_wardrobe()
history = load_history()
liked = sum(1 for h in history if h.get('rating') == 'like')
bio = p.get('bio', 'No bio yet — tell your closet twin about your style.')
return f"""
<div style="
width: 100%; border-radius: 1.5rem; overflow: hidden;
box-shadow:8px 8px 0 rgba(147,5,0,.12); background:rgba(247,240,230,.96); border:1px solid rgba(147,5,0,.22); margin-bottom: 2rem;
">
<div style="height:190px;width:100%;position:relative;{banner_style}">
<div style="position:absolute;inset:0;background:linear-gradient(to bottom,rgba(255,253,249,0.05),rgba(255,253,249,0.35));"></div>
<div style="
position:absolute;bottom:-50px;left:2.5rem;
width:100px;height:100px;border-radius:0;
border:4px solid rgba(255,255,255,0.9);
box-shadow:0 8px 24px rgba(140,114,101,.2);
overflow:hidden;background:#FDFBFC;
">
<img src="{avatar}" alt="Avatar" style="width:100%;height:100%;object-fit:cover;">
</div>
</div>
<div style="padding: 3.5rem 2.5rem 1.5rem; display:flex; justify-content:space-between; align-items:flex-end; flex-wrap:wrap; gap:1rem;">
<div>
<div style="font-family:'Bodoni Moda',serif;font-size:2rem;font-weight:700;color:#930500;line-height:1.1;">{p.get('name','Your Name')}</div>
<div style="font-size:.75rem;letter-spacing:.1em;text-transform:uppercase;color:#7d2b27;margin:.3rem 0 .7rem;">Twin ID #{p.get('twin_id','0001')}</div>
<span style="background:#C9DAEA;color:#930500;font-size:.75rem;font-weight:600;padding:.25rem 1rem;border-radius:0;border:1px dashed #C9DAEA;">Wardrobe Owner</span>
</div>
<div style="display:flex;gap:2.5rem;">
<div style="text-align:center">
<div style="font-family:'Bodoni Moda',serif;font-size:2.2rem;font-weight:700;color:#930500;">{len(wardrobe)}</div>
<div style="font-size:.72rem;color:#7d2b27;text-transform:uppercase;letter-spacing:.06em;">Items</div>
</div>
<div style="text-align:center">
<div style="font-family:'Bodoni Moda',serif;font-size:2.2rem;font-weight:700;color:#930500;">{len(history)}</div>
<div style="font-size:.72rem;color:#7d2b27;text-transform:uppercase;letter-spacing:.06em;">Outfits</div>
</div>
<div style="text-align:center">
<div style="font-family:'Bodoni Moda',serif;font-size:2.2rem;font-weight:700;color:#930500;">{liked}</div>
<div style="font-size:.72rem;color:#7d2b27;text-transform:uppercase;letter-spacing:.06em;">Liked</div>
</div>
</div>
</div>
<div style="padding: 0 2.5rem 2rem;">
<p style="font-size:.92rem;line-height:1.7;color:#7d2b27;font-style:italic;">"{bio}"</p>
</div>
</div>"""
def render_landing_html():
polaroids = f"""
<div class="ct-polaroid" style="width:140px;transform:rotate(-3deg)">
<div class="ct-clip"></div>
<div class="ct-photo" style="height:130px;background-color:#FAF5EF;"><img src="{static_url('ui/heartsgif.gif')}" alt="Hearts"></div>
<div class="ct-caption" style="font-family:'Sacramento', cursive;color:var(--ct-dpink)">favorites</div>
</div>
<div class="ct-polaroid" style="width:140px;transform:rotate(2deg)">
<div class="ct-clip"></div>
<div class="ct-photo" style="height:130px;background-color:#FAF5EF;"><img src="{static_url('ui/fitgit.gif')}" alt="Fits"></div>
<div class="ct-caption" style="font-family:'Sacramento', cursive;color:var(--ct-dpink)">my style</div>
</div>
<div class="ct-polaroid" style="width:140px;transform:rotate(-1deg)">
<div class="ct-clip"></div>
<div class="ct-photo" style="height:130px;background-color:#FAF5EF;"><img src="{static_url('ui/ribbonsgif.gif')}" alt="Ribbons"></div>
<div class="ct-caption" style="font-family:'Sacramento', cursive;color:var(--ct-dpink)">Closet Twin</div>
</div>
"""
return f"""
<div class="ct-landing">
<!-- Hero -->
<div class="ct-hero">
<div class="ct-hero-polaroids">{polaroids}</div>
<div class="ct-hero-text">
<h1 class="ct-hero-title">Closet<em>Twin</em></h1>
<p class="ct-hero-sub">An AI stylist built for one real person &mdash; you.</p>
<p class="ct-hero-desc">Stop standing in front of your wardrobe wondering what to wear. Upload your clothes once, and let your personal AI twin build complete, occasion-ready outfits from everything you already own.</p>
<div class="ct-hero-cta-placeholder"></div>
</div>
</div>
<!-- Divider -->
<div class="ct-ribbon-divider"><span class="ct-script" style="font-size:1.6rem;color:var(--ct-pink)">from the stylist</span></div>
<!-- Scrapbook Section (isnpo2.jpg style) -->
<div class="ct-scrapbook">
<!-- Gold-framed journal card -->
<div class="ct-journal-frame">
<div class="ct-journal-stripe"></div>
<div class="ct-journal-inner">
<span class="ct-script" style="font-size:1.35rem;color:var(--ct-pink)">the styling journal</span>
<h2 class="ct-section-h2">how it works</h2>
<div class="ct-divider-dashed"></div>
<p class="ct-body-text">Closet Twin uses a local vision model to identify every garment you upload &mdash; reading its color, formality, season suitability, and cut. A language model then acts as your personal stylist, building complete outfit proposals from <em>only</em> what already hangs in your wardrobe.</p>
<div class="ct-quote-box">
<span class="ct-script" style="font-size:1.25rem">reduced decision fatigue<br>100% sustainable styling</span>
</div>
</div>
</div>
<!-- Envelope-style features card -->
<div class="ct-envelope-card">
<div class="ct-envelope-top"></div>
<div style="float:left; width:28px; height:28px; background: url('{static_url('ui/pin.png')}') no-repeat center; background-size: contain; margin-top: 5px; opacity:0.85;"></div>
<span class="ct-script" style="float:right;font-size:1.2rem;color:var(--ct-pink)">features</span>
<h2 class="ct-section-h2" style="margin-top:.25rem; clear:both;">what your twin can do</h2>
<div class="ct-divider-dashed"></div>
<ul class="ct-feature-list">
<li>Build outfit options around a specific garment you want to wear today</li>
<li>Adapt recommendations by weather temperature and occasion type</li>
<li>Learn your color and fit preferences through feedback over time</li>
<li>Recreate Pinterest and Instagram looks using your existing wardrobe</li>
<li>Highlight underused clothing items so nothing gets forgotten</li>
<li>Save favorite combinations to your personal collection</li>
</ul>
</div>
</div>
<!-- CTA strip -->
<div class="ct-cta-strip">
<h2 class="ct-script" style="font-size:2.5rem;color:var(--ct-brown);margin:0">ready to meet your closet twin?</h2>
<div style="margin-top:1.5rem;display:flex;gap:1rem;flex-wrap:wrap;justify-content:center">
<div class="ct-cta-strip-placeholder"></div>
</div>
</div>
</div>"""
# ── CSS ────────────
CUSTOM_CSS = """
footer.svelte-mpyp5e, footer { display: none !important; }
:root {
--ct-cream: #FDFBFC;
--ct-paper: #FDFBFC;
--ct-red: #930500;
--ct-red-soft: #B10B07;
--ct-blue: #C9DAEA;
--ct-blue-soft: #C8DCF6;
--ct-ink: #930500;
--ct-muted: #7d2b27;
--ct-line: rgba(147, 5, 0, 0.15);
--ct-shadow: rgba(86, 0, 0, 0.18);
--ct-white: #FDFBFC;
--ct-pink: #930500;
--ct-lpink: #FFE9E4;
--ct-dpink: #930500;
--ct-sage: #C9DAEA;
--ct-lsage: #E8F2FF;
--ct-dsage: #315F9D;
--ct-taupe: #7d2b27;
--ct-ltaupe: #a75a55;
--ct-brown: #930500;
--ct-deep: #650000;
--card-bg: #FDFBFC;
}
html, body, gradio-app, #root, .app, .main, .gradio-container {
min-height: 100vh;
margin: 0 !important;
padding: 0 !important;
color: var(--ct-ink) !important;
font-family: 'Space Grotesk', 'Inter', Arial, sans-serif !important;
background-color: var(--ct-cream) !important;
background-image:
linear-gradient(rgba(147, 5, 0, .08) 1px, transparent 1px),
linear-gradient(90deg, rgba(147, 5, 0, .08) 1px, transparent 1px) !important;
background-repeat: repeat !important;
background-size: 32px 32px, 32px 32px !important;
background-attachment: fixed !important;
}
.gradio-container {
max-width: 100% !important;
width: 100% !important;
padding-top: 0 !important;
padding-left: 0 !important;
padding-right: 0 !important;
background: transparent !important;
}
#ct-style-injector,
#ct-style-injector > div,
#ct-style-injector .prose {
display: none !important;
width: 0 !important;
height: 0 !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
}
gradio-app,
gradio-app > div,
#root,
#root > div,
.gradio-container > div:first-child,
.gradio-container .main:first-child,
.gradio-container .contain:first-child {
margin-top: 0 !important;
padding-top: 0 !important;
}
.gradio-container > .main,
.gradio-container > main,
.gradio-container main,
.gradio-container .main,
.gradio-container .app,
.gradio-container .contain,
.gradio-container .wrap {
max-width: none !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
.gr-prose, .gr-form, .gr-padded, .html-container,
.gradio-container .block, .gradio-container .panel,
.gradio-container .gr-group, .gradio-container .form,
.gradio-container .wrap:not(input):not(select):not(textarea) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.ct-navbar {
width: 100vw !important;
max-width: none !important;
margin: 0 calc(50% - 50vw) !important;
min-height: 76px !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
background: #FED4E0 !important;
color: var(--ct-red) !important;
border: 0 !important;
border-bottom: 1px solid rgba(147, 5, 0, .18) !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow: visible !important;
}
.ct-navbar,
.ct-navbar.wrap,
.ct-navbar.row,
.ct-navbar.block,
.ct-navbar.svelte-1ed2p3z {
margin-top: 0 !important;
}
.ct-navbar:after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -17px;
height: 18px;
background: radial-gradient(circle at 14px 0, transparent 13px, #FED4E0 14px) repeat-x;
background-size: 28px 18px;
pointer-events: none;
}
.ct-navbar,
.ct-navbar > div,
.ct-navbar-inner,
.ct-navbar-inner > div {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 0 !important;
flex-wrap: nowrap !important;
}
.gradio-container .ct-navbar.column,
.gradio-container .ct-navbar .column,
.gradio-container .ct-navbar-inner.row,
.gradio-container .ct-nav-links.row {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
flex-wrap: nowrap !important;
}
.ct-navbar-logo-wrap,
.ct-navbar-logo-wrap > div {
display: flex !important;
flex: 0 0 auto !important;
align-items: center !important;
}
.ct-logo {
font-family: 'Shrikhand', Georgia, serif;
font-size: clamp(1.45rem, 2.2vw, 2rem);
color: var(--ct-red);
letter-spacing: 0;
line-height: 1;
text-transform: lowercase;
display: flex;
align-items: center;
gap: .55rem;
white-space: nowrap;
}
.ct-logo-title {
font-family: 'Shrikhand', Georgia, serif !important;
font-size: clamp(1.45rem, 2.2vw, 2rem) !important;
color: var(--ct-red) !important;
line-height: 1 !important;
text-transform: lowercase !important;
white-space: nowrap !important;
}
.ct-logo img { height: 24px !important; width: 24px !important; object-fit: contain; }
.ct-nav-links {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: flex-end !important;
gap: .45rem !important;
flex-wrap: nowrap !important;
min-width: 0 !important;
overflow-x: auto !important;
opacity: 1 !important;
pointer-events: auto !important;
scrollbar-width: none;
}
.ct-nav-links > div {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: .45rem !important;
flex-wrap: nowrap !important;
}
.ct-nav-links::-webkit-scrollbar { height: 0; }
.ct-nav-links button, .ct-nav-links button.secondary {
width: auto !important;
min-width: unset !important;
white-space: nowrap !important;
padding: .55rem .8rem !important;
background: transparent !important;
border: 1px solid transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
color: var(--ct-red) !important;
font-family: 'Space Grotesk', sans-serif !important;
font-size: .72rem !important;
font-weight: 800 !important;
letter-spacing: .06em !important;
text-transform: uppercase !important;
}
.ct-nav-links button:hover, .ct-nav-links button.secondary:hover {
background: var(--ct-red) !important;
color: var(--ct-cream) !important;
transform: translateY(-1px) !important;
}
.ct-page {
width: min(1480px, calc(100% - 2rem)) !important;
max-width: 1480px !important;
margin: 1.25rem auto 2.5rem !important;
padding: clamp(1rem, 2.4vw, 2rem) clamp(1rem, 5vw, 4rem);
box-sizing: border-box;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.ct-page > div {
width: 100% !important;
max-width: 100% !important;
}
.gradio-container .ct-page,
.gradio-container .ct-page.column,
.gradio-container [id^="ct-page-"] {
width: min(1480px, calc(100% - 2rem)) !important;
max-width: 1480px !important;
margin-left: auto !important;
margin-right: auto !important;
align-self: center !important;
}
.ct-page:before {
content: none !important;
}
.ct-page-heading, .ct-card-title {
font-family: 'Bodoni Moda', Georgia, serif !important;
color: var(--ct-red) !important;
font-weight: 500 !important;
letter-spacing: 0 !important;
text-transform: none !important;
line-height: 1 !important;
border-bottom: 1px solid var(--ct-line) !important;
margin: 0 0 1.15rem !important;
padding-bottom: .65rem !important;
background: transparent !important;
display: block !important;
}
.ct-page-heading { font-size: clamp(2.1rem, 5vw, 4.2rem) !important; }
.ct-card-title { font-size: clamp(1.45rem, 2.5vw, 2.4rem) !important; }
.ct-card-title-inline, .ct-section-h2, .ct-empty-title, .ct-wear-title, .ct-sc-title {
font-family: 'Bodoni Moda', Georgia, serif !important;
color: var(--ct-red) !important;
letter-spacing: 0 !important;
line-height: 1 !important;
background: transparent !important;
}
.ct-script, .ct-caption, .ct-hero-sub {
font-family: 'Sacramento', 'Brush Script MT', cursive !important;
color: var(--ct-red) !important;
}
.ct-col-flat, .ct-col-flat > div, .ct-col-flat > .block { background: transparent !important; border: none !important; box-shadow: none !important; }
.ct-card, .ct-sc-root, .ct-history-accordion, .ct-reason-panel, .ct-wear-left,
.ct-stylist-empty, .ct-envelope-card, .ct-journal-frame, .ct-cta-strip {
background: rgba(255, 246, 219, 0.6) !important;
border: 1px solid rgba(147, 5, 0, .20) !important;
border-radius: 0 !important;
box-shadow: 8px 8px 0 rgba(147, 5, 0, .10) !important;
}
.ct-card, .ct-envelope-card, .ct-journal-inner, .ct-cta-strip { padding: 1.35rem !important; }
.ct-card-dashed { border-style: dashed !important; }
.ct-card:hover { box-shadow: 10px 10px 0 rgba(147, 5, 0, .14) !important; }
.ct-card-header { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap; }
.gradio-container button.primary,
.gradio-container button.secondary,
.ct-cta-btn, .ct-cta-btn-outline, .ct-sc-dl-btn, .ct-sc-btn {
border-radius: 0 !important;
font-family: 'Space Grotesk', sans-serif !important;
font-weight: 800 !important;
letter-spacing: .05em !important;
text-transform: uppercase !important;
transition: transform .16s ease, box-shadow .16s ease, background .16s ease !important;
}
.gradio-container button.primary, .ct-cta-btn {
width: 100% !important;
background: var(--ct-red) !important;
border: 1px solid var(--ct-red) !important;
color: var(--ct-cream) !important;
box-shadow: 5px 5px 0 var(--ct-blue) !important;
}
.gradio-container button.secondary, .ct-cta-btn-outline, .ct-sc-dl-btn, .ct-sc-btn {
width: 100% !important;
background: var(--ct-blue) !important;
border: 1px solid var(--ct-red) !important;
color: var(--ct-red) !important;
box-shadow: 4px 4px 0 rgba(147, 5, 0, .18) !important;
}
.gradio-container button:hover, .ct-cta-btn:hover, .ct-sc-dl-btn:hover, .ct-sc-btn:hover {
transform: translate(-2px, -2px) !important;
box-shadow: 7px 7px 0 rgba(147, 5, 0, .24) !important;
}
.gradio-container button:active { transform: translate(1px, 1px) !important; box-shadow: 2px 2px 0 rgba(147, 5, 0, .2) !important; }
.gradio-container fieldset {
display: flex !important;
gap: .45rem !important;
flex-wrap: wrap !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
}
.gradio-container fieldset legend, .gradio-container label, .gradio-container .block-label {
font-family: 'Space Grotesk', sans-serif !important;
color: var(--ct-red) !important;
font-weight: 700 !important;
letter-spacing: .03em !important;
}
.gradio-container fieldset label {
background: var(--ct-cream) !important;
border: 1px solid var(--ct-line) !important;
border-radius: 0 !important;
padding: .36rem .8rem !important;
cursor: pointer !important;
}
.gradio-container fieldset label:has(input[type=radio]:checked) {
background: var(--ct-red) !important;
color: var(--ct-cream) !important;
border-color: var(--ct-red) !important;
}
.gradio-container fieldset input[type=radio] { position:absolute; opacity:0; width:0; height:0; }
.gradio-container input[type=range] { accent-color: var(--ct-red) !important; }
.gradio-container input[type=text], .gradio-container input[type=number],
.gradio-container textarea, .gradio-container input[type=email], .gradio-container select {
border-radius: 0 !important;
border: 1px solid rgba(147, 5, 0, .28) !important;
background: rgba(255, 246, 219, 0.6) !important;
color: var(--ct-red) !important;
font-family: 'Space Grotesk', sans-serif !important;
box-shadow: none !important;
}
.gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus {
outline: 2px solid var(--ct-blue) !important;
border-color: var(--ct-red) !important;
}
.gradio-container input::placeholder, .gradio-container textarea::placeholder { color: rgba(147, 5, 0, .48) !important; }
.gradio-container .image-container, .gradio-container .upload-container { border-radius: 0 !important; border-color: var(--ct-line) !important; }
.ct-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; }
.ct-polaroid {
background: var(--ct-cream) !important;
border: 1px solid rgba(147, 5, 0, .22) !important;
border-radius: 0 !important;
padding: .65rem .65rem 1.15rem !important;
box-shadow: 5px 5px 0 rgba(147, 5, 0, .10) !important;
position: relative;
text-align: center;
transition: transform .18s ease, box-shadow .18s ease;
}
.ct-polaroid:hover { transform: rotate(-1deg) translateY(-2px); box-shadow: 7px 7px 0 rgba(147, 5, 0, .16) !important; }
.ct-photo { width:100%; height:160px; overflow:hidden; background: #fff; display:flex; align-items:center; justify-content:center; margin-bottom:.55rem; border:1px solid rgba(147, 5, 0, .12); }
.ct-photo img { width:100%; height:100%; object-fit:cover; display:block; }
.ct-caption { font-size: 1.35rem; line-height: 1.05; }
.ct-meta { font-size: .68rem; color: var(--ct-muted); margin-top:.18rem; text-transform:uppercase; letter-spacing:.04em; }
.ct-selected-pill { position:absolute; top:.75rem; right:.75rem; background:var(--ct-red); color:var(--ct-cream); padding:.25rem .55rem; font-size:.65rem; text-transform:uppercase; letter-spacing:.06em; }
.ct-clip { position:absolute; top:-8px; left:50%; width:48px; height:14px; transform:translateX(-50%) rotate(-2deg); background:rgba(149,187,234,.78); border:1px solid rgba(147,5,0,.18); }
.ct-clip:after { display:none; }
.ct-landing { display:flex; flex-direction:column; gap:2rem; }
.ct-hero {
min-height: 520px;
display:grid;
grid-template-columns: minmax(260px,.85fr) minmax(300px,1fr);
align-items:center;
gap: clamp(1.5rem, 4vw, 4rem);
padding: clamp(1rem, 4vw, 3rem) 0 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.ct-hero-polaroids { display:flex; gap:.8rem; flex-wrap:wrap; justify-content:center; }
.ct-hero-title {
font-family: 'Bodoni Moda', Georgia, serif !important;
font-size: clamp(4rem, 10vw, 8.5rem) !important;
font-weight: 500 !important;
line-height: .82 !important;
color: var(--ct-red) !important;
letter-spacing: 0 !important;
margin: 0 0 1rem !important;
}
.ct-hero-title em { display:block; font-family:'Sacramento', cursive !important; font-size:.62em; font-style:normal; color:var(--ct-red) !important; margin-top:.08em; }
.ct-hero-sub { font-size: clamp(2rem, 4.5vw, 4.3rem) !important; margin:0 0 .8rem !important; line-height:.9 !important; }
.ct-hero-desc, .ct-body-text, .ct-reason-panel p, .ct-sc-note {
color: var(--ct-muted) !important;
line-height: 1.6 !important;
}
.ct-ribbon-divider { display:flex; align-items:center; gap:1rem; }
.ct-ribbon-divider:before, .ct-ribbon-divider:after { content:''; flex:1; border-top:1px solid var(--ct-line); }
.ct-divider-dashed, .ct-panel-divider { border-top:1px dashed var(--ct-line) !important; margin:.75rem 0 1rem; }
.ct-scrapbook { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
.ct-journal-frame { padding:0 !important; overflow:hidden; }
.ct-journal-stripe, .ct-envelope-top { height:16px; background: repeating-linear-gradient(90deg, var(--ct-red) 0 18px, #FED4E0 18px 36px) !important; margin:0 !important; }
.ct-quote-box, .ct-reasoning, .ct-reasoning-pink, .ct-progress-wrapper {
background: var(--ct-blue) !important;
border: 1px solid var(--ct-red) !important;
border-radius: 0 !important;
color: var(--ct-red) !important;
}
.ct-feature-list { list-style:none; padding:0; margin:.75rem 0 0; }
.ct-feature-list li { border-bottom:1px solid var(--ct-line); padding:.55rem 0; color:var(--ct-red); font-family:'Space Grotesk', sans-serif; }
.ct-feature-list li:before { content:'+'; color:var(--ct-red); margin-right:.55rem; font-weight:800; }
.ct-cta-strip { text-align:center; background: var(--ct-blue) !important; }
.ct-sc-root {
max-width: 680px;
margin:0 auto 1.5rem;
overflow:hidden;
background: rgba(247,240,230,.98) !important;
border: 1px solid rgba(147,5,0,.18) !important;
box-shadow: 8px 8px 0 rgba(255,199,214,.55) !important;
}
.ct-sc-header {
display:flex;
justify-content:space-between;
align-items:center;
gap:1rem;
padding:.85rem 1rem;
border-bottom:1px dashed rgba(147,5,0,.22);
background: rgba(255,199,214,.28);
}
.ct-sc-body { padding:1rem 1.15rem 1.15rem; }
.ct-sc-slide { display:grid; gap:.9rem; }
.ct-sc-img-wrap {
position:relative;
width:min(100%, 340px);
height:410px;
margin:0 auto;
border:1px solid rgba(147,5,0,.18);
background:#fff;
display:flex;
align-items:center;
justify-content:center;
overflow:hidden;
}
.ct-sc-img {
width:100%;
height:100%;
object-fit:contain;
display:block;
}
.ct-sc-badge, .ct-sc-energy, .ct-hist-badge, .ct-garment-chip {
background: rgba(255,199,214,.72) !important;
color: var(--ct-red) !important;
border: 1px dashed rgba(147,5,0,.38) !important;
border-radius: 0 !important;
}
.ct-sc-badge {
position:absolute;
left:.65rem;
bottom:.65rem;
padding:.18rem .45rem;
font-size:.72rem;
font-weight:800;
text-transform:uppercase;
letter-spacing:.04em;
}
.ct-sc-energy {
display:inline-block;
width:auto !important;
justify-self:start;
padding:.25rem .65rem;
font-size:.78rem;
}
.ct-sc-info {
display:grid;
gap:.4rem;
max-width: 520px;
margin: 0 auto;
width: 100%;
}
.ct-sc-name {
font-family:'Bodoni Moda', Georgia, serif;
font-size:1.25rem;
font-weight:700;
color:var(--ct-red);
}
.ct-sc-meta, .ct-hist-occasion, .ct-feedback-note, .ct-empty-sm { color:var(--ct-muted) !important; }
.ct-sc-dl-btn {
justify-self:start;
margin-top:.25rem;
text-decoration:none !important;
background:var(--ct-red) !important;
color:var(--ct-cream) !important;
border:1px solid var(--ct-red) !important;
padding:.5rem .9rem !important;
font-size:.78rem !important;
font-weight:800 !important;
letter-spacing:.06em !important;
text-transform:uppercase !important;
}
.ct-stylist-empty { min-height:360px; display:flex; flex-direction:column; justify-content:center; align-items:center; text-align:center; padding:2rem; }
.ct-empty { text-align:center; padding:2.5rem 1rem; color:var(--ct-muted); font-family:'Bodoni Moda', Georgia, serif; font-size:1.45rem; }
.ct-wear-result { max-width:980px; margin:0 auto; }
.ct-wear-kicker, .ct-side-label { color:var(--ct-muted); font-size:.72rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em; }
.ct-wear-title { text-align:center; font-size:clamp(2.4rem, 5vw, 4.8rem); line-height:.9; margin:.15rem 0 1.2rem; }
.ct-wear-layout { display:grid; grid-template-columns:minmax(280px,1.1fr) minmax(260px,.9fr); gap:1.5rem; align-items:start; }
.ct-result-piece-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; }
.ct-result-piece { min-height:190px; background:var(--ct-cream); border:1px solid var(--ct-line); display:flex; flex-direction:column; justify-content:space-between; overflow:hidden; }
.ct-result-piece img { width:100%; height:160px; object-fit:cover; display:block; }
.ct-result-piece div { padding:.55rem .65rem .7rem; }
.ct-result-piece strong { display:block; color:var(--ct-red); font-size:.82rem; }
.ct-result-piece span, .ct-garment-chip small { color:var(--ct-muted); font-size:.64rem; text-transform:uppercase; }
.ct-look-caption { margin-top:.8rem; padding:.7rem; text-align:center; color:var(--ct-red); border:1px solid var(--ct-line); font-family:'Sacramento', cursive; font-size:1.7rem; line-height:1; }
.ct-garment-chip-grid { display:flex; flex-wrap:wrap; gap:.45rem; margin-bottom:1rem; }
.ct-garment-chip { display:inline-flex; gap:.35rem; align-items:center; padding:.38rem .65rem; }
.ct-reason-panel { padding:1rem !important; }
.ct-slot-tools { margin-top:1rem; }
.ct-slot-tools-title, .ct-slot-label { color:var(--ct-red); font-family:'Space Grotesk', sans-serif; font-weight:800; text-transform:uppercase; letter-spacing:.06em; }
.ct-slot-row { align-items:center; }
.ct-icon-button button { min-width:42px !important; }
.ct-history { display:flex; flex-direction:column; gap:.45rem; }
.ct-history-summary { list-style:none; cursor:pointer; padding:.65rem .85rem; }
.ct-history-summary::-webkit-details-marker { display:none; }
.ct-history-summary-inner { display:flex; justify-content:space-between; align-items:center; gap:.5rem; }
.ct-history-body { border-top:1px dashed var(--ct-line); padding:.55rem .85rem .8rem; color:var(--ct-red); }
.ct-progress { position:relative; width:100%; height:10px; background:rgba(247,240,230,.55); overflow:hidden; }
.ct-progress-bar { position:absolute; top:0; left:-40%; width:40%; height:100%; background:var(--ct-red); animation:ct-progress 1.35s ease-in-out infinite; }
@keyframes ct-progress { 0%{left:-40%;} 50%{left:50%;} 100%{left:120%;} }
.ct-footer {
width: 100vw !important;
max-width: none !important;
margin: 0 calc(50% - 50vw) 0 !important;
padding: 1.2rem 2rem;
background:var(--ct-red);
color:white !important;
border: 0;
border-top: 1px solid rgba(247,240,230,.28);
text-align:center;
box-sizing: border-box;
}
.ct-footer-title {
font-family:'Bodoni Moda', Georgia, serif;
font-size:1.35rem;
margin:0 0 .25rem;
color:#FFF2D6 !important;
}
.ct-footer-sub {
color:#F7E4BE !important;
font-size:.78rem;
margin:0;
}
/* Final navbar override: keep Gradio wrappers from collapsing/hiding links. */
.gradio-container .ct-navbar {
width: 100vw !important;
max-width: none !important;
margin: 0 calc(50% - 50vw) !important;
padding: 0 !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
overflow: visible !important;
}
.gradio-container .ct-navbar-inner {
width: min(1280px, 100%) !important;
display: flex !important;
flex-direction: row !important;
justify-content: space-between !important;
align-items: center !important;
gap: 1rem !important;
padding: 0 1.5rem !important;
box-sizing: border-box !important;
}
.gradio-container .ct-navbar-logo-wrap,
.gradio-container .ct-navbar-logo-wrap > div {
display: flex !important;
flex: 0 0 auto !important;
align-items: center !important;
width: auto !important;
min-width: max-content !important;
}
.gradio-container .ct-nav-links,
.gradio-container .ct-nav-links > div {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: flex-end !important;
gap: .45rem !important;
flex-wrap: nowrap !important;
width: auto !important;
min-width: 0 !important;
overflow-x: auto !important;
opacity: 1 !important;
pointer-events: auto !important;
}
.gradio-container #nav-home,
.gradio-container #nav-closet,
.gradio-container #nav-stylist,
.gradio-container #nav-favorites,
.gradio-container #nav-analytics,
.gradio-container #nav-profile {
display: flex !important;
flex: 0 0 auto !important;
width: auto !important;
min-width: max-content !important;
visibility: visible !important;
opacity: 1 !important;
}
.gradio-container #nav-home button,
.gradio-container #nav-closet button,
.gradio-container #nav-stylist button,
.gradio-container #nav-favorites button,
.gradio-container #nav-analytics button,
.gradio-container #nav-profile button {
display: inline-flex !important;
width: auto !important;
min-width: max-content !important;
visibility: visible !important;
opacity: 1 !important;
white-space: nowrap !important;
padding: .55rem .8rem !important;
background: transparent !important;
color: var(--ct-red) !important;
border-color: transparent !important;
box-shadow: none !important;
}
.gradio-container #nav-home button:hover,
.gradio-container #nav-closet button:hover,
.gradio-container #nav-stylist button:hover,
.gradio-container #nav-favorites button:hover,
.gradio-container #nav-analytics button:hover,
.gradio-container #nav-profile button:hover {
background: var(--ct-red) !important;
color: var(--ct-cream) !important;
}
@media (max-width: 850px) {
.ct-navbar { position:relative !important; top:auto !important; flex-direction:row !important; height:auto !important; padding:.8rem !important; gap:.7rem !important; overflow-x:auto !important; align-items:center !important; }
.ct-navbar::-webkit-scrollbar { height: 0; }
.ct-logo { flex:0 0 auto; }
.ct-nav-links { justify-content:flex-start !important; flex-wrap:nowrap !important; flex:0 0 auto !important; }
.ct-nav-links button, .ct-nav-links button.secondary { white-space:nowrap !important; }
.ct-page { width:100% !important; margin:1.25rem 0 !important; }
.ct-hero, .ct-scrapbook, .ct-wear-layout { grid-template-columns:1fr !important; }
.ct-hero { min-height:auto; }
.ct-result-piece-grid { grid-template-columns:1fr; }
}
"""
# ── Gradio Callbacks ──────────────────────────────────────────────────────────
def switch_tab(tab):
tabs = ["home","closet","stylist","favorites","analytics","profile"]
return tuple(gr.update(visible=(tab==t)) for t in tabs)
def on_category(cat, selected_id):
return render_wardrobe_html(cat, selected_id)
def on_delete(item_id, cat):
w = [i for i in load_wardrobe() if i["id"]!=item_id]
save_wardrobe(w)
for p in [f"images/edited/{item_id}_edited.png", f"images/original/{item_id}.png"]:
if os.path.exists(p): os.remove(p)
return render_wardrobe_html(cat), render_analytics_html()
def on_wear(item_id):
wm = {i["id"]:i for i in load_wardrobe()}
if item_id not in wm: return "", "", "home"
wi = wm[item_id]
html = f"""<div style="display:flex;justify-content:center">
<div class="ct-polaroid" style="max-width:140px">
<div class="ct-clip"></div>
<div class="ct-photo" style="height:110px"><img src="{img_src(item_id)}" onerror="this.src='{IMG_FALLBACK}'" alt=""></div>
<div class="ct-caption">{normalize_item_type(wi.get('type','item'))}</div>
</div></div>"""
return item_id, html, "stylist"
def on_anchor_select(selection, category):
item_id = parse_item_id(selection)
if not item_id:
return "", "<div class='ct-empty'>No item selected.</div>", gr.update(visible=False), render_wardrobe_html(category, None)
wm = {i["id"]:i for i in load_wardrobe()}
item = wm.get(item_id)
if not item:
return "", "<div class='ct-empty'>Selected item not found.</div>", gr.update(visible=False), render_wardrobe_html(category, None)
cols = ", ".join(item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")])
html = f"""
<div class='ct-card' style='background:var(--ct-lsage);border:1px dashed var(--ct-sage);padding:1.6rem 1rem 1rem;position:relative;'>
<div style="position:absolute; top:-12px; left:50%; transform:translateX(-50%); width:26px; height:26px; background: url('{static_url('ui/pin.png')}') no-repeat center; background-size:contain; z-index:10;"></div>
<strong style='display:block;margin-bottom:.7rem;text-align:center;'>Selected item</strong>
<div style='display:flex;align-items:center;gap:.85rem'>
<div style='width:60px;height:60px;border-radius:16px;overflow:hidden;border:1px solid rgba(143,158,139,.25);'>
<img src='{img_src(item_id)}' style='width:100%;height:100%;object-fit:cover' />
</div>
<div style='font-size:.95rem;line-height:1.4rem'>
<strong>{normalize_item_type(item.get('type','item'))}</strong><br>
<span style='color:var(--ct-taupe)'>{cols} · {item.get('fit','—')}</span>
</div>
</div>
</div>"""
return item_id, html, gr.update(visible=True), render_wardrobe_html(category, item_id)
def on_upload(path):
if path is None:
return (gr.update(visible=False),)+(("",)*10)+(None,)
img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB")
try:
_, edited_path, image_id = process_image(img)
edited = Image.open(edited_path).convert("RGB")
except Exception as ex:
print("FLUX fallback:", ex)
image_id = str(uuid.uuid4())
img.save(os.path.join(ORIGINAL_IMAGE_DIR, f"{image_id}.png"))
img.save(os.path.join(EDITED_IMAGE_DIR, f"{image_id}_edited.png"))
edited = img
attrs = {}
if HAS_LOCAL_MODELS:
try: attrs = extract_attributes(edited)
except Exception as ex: print("extract_attributes fallback:", ex)
if not attrs or "error" in attrs:
attrs = {"category":"top","type":"shirt","color":["white"],"pattern":"none","style":"casual","fit":"regular","season":["all"],"formality":0.3,"description":"clothing item"}
def first(v): return v[0] if isinstance(v,list) and v else (v or "")
def join(v): return ", ".join(v) if isinstance(v,list) else (v or "")
return (
gr.update(visible=True), image_id,
attrs.get("category","top"), first(attrs.get("type","shirt")),
join(attrs.get("color",[])), attrs.get("pattern","none"),
first(attrs.get("style","casual")), attrs.get("fit","regular"),
join(attrs.get("season",[])), float(attrs.get("formality",0.3)),
attrs.get("description",""), fs_path(f"images/edited/{image_id}_edited.png"),
gr.update(visible=False)
)
def on_save(img_id, cat, typ, cols, pat, sty, fit, seasons, formality, desc, active_cat):
item = {
"id": img_id,
"created_at": datetime.datetime.utcnow().isoformat(),
"category": cat,
"type": typ,
"color": [c.strip() for c in cols.split(",") if c.strip()],
"pattern": pat,
"style": sty,
"fit": fit,
"season": [s.strip() for s in seasons.split(",") if s.strip()],
"formality": float(formality),
"description": desc,
"image_original": f"images/original/{img_id}.png",
"image_clean": f"images/edited/{img_id}_edited.png"
}
add_item(item)
return (
render_wardrobe_html(active_cat),
render_landing_html(),
gr.update(choices=item_choices(), value=None),
render_analytics_html(),
gr.update(visible=False),
gr.update(value=None)
)
current_recs = []
def on_style(occasion, weather, temp, mood, style_pref, anchor):
global current_recs
wardrobe = load_wardrobe()
if not wardrobe:
return "<div class='ct-empty'>Upload clothes first before requesting outfit ideas.</div>", "Outfit 1 of 0", 0, ""
recommendation_wardrobe = [item for item in wardrobe if item_has_image(item)]
if anchor and not any(item["id"] == anchor for item in recommendation_wardrobe):
anchor_item = next((item for item in wardrobe if item["id"] == anchor), None)
if anchor_item:
recommendation_wardrobe.append(anchor_item)
if not recommendation_wardrobe:
recommendation_wardrobe = wardrobe
system = (
"You are Closet Twin, a private no-buy AI stylist for one real person. "
"Your principle is: Same Energy, Not Same Clothes. "
"The user already owns enough clothes, so never suggest shopping, prices, brands to buy, or similar products. "
"Understand the user's requested energy naturally, like a friend would: silhouette, colors, formality, softness, structure, confidence, and effort level. "
"You MUST respond ONLY with a valid JSON array containing exactly 3 outfit objects. "
"Do not include markdown, backticks, commentary, or extra text. Only return the raw JSON array. "
"Rules:\n"
"1. Generate exactly 3 distinct outfits: safest wearable option, softer option, and more polished option.\n"
"2. You MUST use ONLY the exact item IDs provided in the user's Wardrobe catalog. Do not invent any new IDs.\n"
"3. Set items to null if not used.\n"
"4. Put each item in the matching slot: dresses in dress, tops in top, bottoms in bottom, shoes in shoes, outerwear in outerwear, accessories in accessory.\n"
"5. A dress is a complete main garment: if dress is not null, top and bottom must be null.\n"
"6. If an anchor item is provided, include it in every outfit unless impossible.\n"
"7. Be practical for someone deciding five minutes before leaving.\n"
"8. Do not make body comments or imply body correction.\n"
"Schema: [{\"name\":\"...\","
"\"energy_detected\":\"short comma-separated vibe phrase\","
"\"items\":{\"dress\":\"id_or_null\",\"top\":\"id_or_null\",\"bottom\":\"id_or_null\","
"\"shoes\":\"id_or_null\",\"outerwear\":\"id_or_null\",\"accessory\":\"id_or_null\"},"
"\"reasoning\":\"2-3 sentences explaining why this works for the requested energy\","
"\"styling_note\":\"one tiny actionable styling note\","
"\"card_prompt\":\"short internal note for a clean flat-lay collage using only selected real items\"}]"
)
catalog = [{
"id": i["id"],
"category": i.get("category"),
"type": i.get("type"),
"color": i.get("color"),
"pattern": i.get("pattern"),
"style": i.get("style"),
"fit": i.get("fit", "regular"),
"season": i.get("season"),
"formality": i.get("formality", 0.5),
"description": i.get("description", ""),
"has_image": item_has_image(i),
} for i in recommendation_wardrobe]
msg = (f"Wardrobe: {json.dumps(catalog)}\nOccasion: {occasion}\nWeather: {weather}, {temp}°C\n"
f"Mood: {mood}\nPreferences: {style_pref}\nStyle profile: {get_style_profile()}\n"
f"Anchor item (must include if not null): {anchor or 'None'}")
try:
if HAS_LOCAL_MODELS:
print("Generating outfit ideas using local MiniCPM-V-4.6 model...")
messages = [
{"role": "user", "content": system + "\n\nUser request:\n" + msg}
]
inputs = get_processor().apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt"
)
inputs = {k: v.to(get_model().device) for k, v in inputs.items()}
with torch.no_grad():
generated_ids = get_model().generate(
**inputs,
max_new_tokens=2048,
temperature=0.4,
do_sample=True
)
generated_ids_trimmed = [
out_ids[len(in_ids):]
for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
]
raw = get_processor().batch_decode(
generated_ids_trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False
)[0]
else:
print("Local models unavailable. Calling Qwen API fallback...")
r = client.chat_completion(model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role":"system","content":system},{"role":"user","content":msg}],
max_tokens=1600, temperature=0.4)
raw = r.choices[0].message.content
raw = raw.replace("```json","").replace("```","").strip()
print("DEBUG raw model output:", repr(raw[:500]))
import re as _re
def parse_model_json(text):
start_idx = text.find('[')
if start_idx == -1:
raise ValueError(f"No JSON array found in model output: {text[:200]}")
decoder = json.JSONDecoder()
try:
parsed, _end = decoder.raw_decode(text, start_idx)
return parsed
except json.JSONDecodeError as decode_error:
depth = 0
end_pos = None
for i, ch in enumerate(text[start_idx:], start_idx):
if ch == '[':
depth += 1
elif ch == ']':
depth -= 1
if depth == 0:
end_pos = i
break
if end_pos is None:
raise decode_error
json_str = text[start_idx:end_pos+1]
json_str = _re.sub(r'[\x00-\x1f]+', ' ', json_str)
json_str = _re.sub(r',\s*(?=[\]}])', '', json_str)
json_str = json_str.replace("'", '"')
return json.loads(json_str)
attempts = 3
parse_error = None
for attempt in range(attempts):
try:
current_recs = parse_model_json(raw)
break
except Exception as error:
parse_error = error
if attempt < attempts - 1:
msg = msg + "\n\nReminder: RETURN EXACTLY 3 OUTFIT OBJECTS IN A SINGLE JSON ARRAY ONLY. No extra text."
if HAS_LOCAL_MODELS:
messages = [{"role": "user", "content": system + "\n\nUser request:\n" + msg}]
inputs = get_processor().apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt"
)
inputs = {k: v.to(get_model().device) for k, v in inputs.items()}
with torch.no_grad():
generated_ids = get_model().generate(
**inputs,
max_new_tokens=2048,
temperature=0.4,
do_sample=True
)
generated_ids_trimmed = [
out_ids[len(in_ids):]
for in_ids, out_ids in zip(inputs["input_ids"], generated_ids)
]
raw = get_processor().batch_decode(
generated_ids_trimmed,
skip_special_tokens=True,
clean_up_tokenization_spaces=False
)[0]
else:
r = client.chat_completion(model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role":"system","content":system},{"role":"user","content":msg}],
max_tokens=1600, temperature=0.4)
raw = r.choices[0].message.content
raw = raw.replace("```json", "").replace("```", "").strip()
continue
raise parse_error
if not isinstance(current_recs, list):
current_recs = [current_recs]
current_recs = sanitize_outfits(current_recs, recommendation_wardrobe, anchor)
current_recs = ensure_three_outfits(current_recs, recommendation_wardrobe, anchor)
wm = {i["id"]:i for i in wardrobe}
if not current_recs:
return (
"<div class='ct-empty' style='color:var(--ct-dpink)'>No valid no-buy outfit could be built from visible wardrobe items.</div>",
"Outfit 1 of 0",
0,
""
)
h = load_history()
for o in current_recs:
h.append({"id":str(uuid.uuid4()),"timestamp":datetime.datetime.now().isoformat(),
"situation":{"occasion":occasion,"weather":weather,"temp":temp,"mood":mood},
"outfit":o,"rating":"generated"})
save_history(h)
return render_outfits_html(current_recs, wm, 0), f"Outfit 1 of {len(current_recs)}", 0, ""
except Exception as ex:
return (f"<div class='ct-empty' style='color:var(--ct-dpink)'>Stylist error: {ex}</div>",
"Outfit 1 of 0", 0, "")
def on_style_slide(direction, current_idx):
global current_recs
if not current_recs:
return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, ""
idx = int(current_idx or 0)
idx = (idx + int(direction)) % len(current_recs)
if idx < 0:
idx += len(current_recs)
return render_outfits_html(current_recs, {i["id"]:i for i in load_wardrobe()}, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, ""
def generate_visual_card_for_index(idx_s):
global current_recs
try:
idx = int(idx_s or 0)
except Exception:
raise ValueError("Generate an outfit first, then create an outfit card.")
if not (0 <= idx < len(current_recs)):
raise ValueError("Generate an outfit first, then create an outfit card.")
wardrobe_map = {i["id"]: i for i in load_wardrobe()}
outfit = current_recs[idx]
card_path, url, item_count = create_outfit_collage(outfit, wardrobe_map)
return outfit, card_path, url, item_count
def visual_card_html(url):
return f"""
<div class="ct-generated-card" style="margin-top:1rem;text-align:center">
<img src="{asset_url(url)}" alt="Closet Twin outfit collage">
</div>"""
def visual_card_error_html(message):
return f"""
<div class="ct-empty" style="color:var(--ct-dpink);text-align:left">
{escape(str(message))}
</div>"""
def on_generate_visual_card(idx_s):
try:
_outfit, _card_path, url, _item_count = generate_visual_card_for_index(idx_s)
return visual_card_html(url)
except Exception as ex:
return visual_card_error_html(f"Outfit card generation failed: {ex}")
def on_save_outfit_card(idx_s):
try:
outfit, card_path, url, _item_count = generate_visual_card_for_index(idx_s)
except Exception as ex:
return visual_card_error_html(f"Could not save outfit card: {ex}"), render_favorites_html(), render_saved_cards_html()
favs = load_favorites()
outfit_items = outfit.get("items", {})
outfit_item_set = {item_id for item_id in outfit_items.values() if item_id}
existing = None
for fav in favs:
fav_item_set = {item_id for item_id in fav.get("items", {}).values() if item_id}
if fav_item_set == outfit_item_set:
existing = fav
break
if existing is None:
existing = {
"id": str(uuid.uuid4()),
"saved_at": datetime.datetime.now().strftime("%Y-%m-%d"),
}
favs.append(existing)
existing.update({
"name": outfit.get("name") or "Saved outfit card",
"items": outfit_items,
"reasoning": outfit.get("reasoning"),
"energy_detected": outfit.get("energy_detected"),
"styling_note": outfit.get("styling_note"),
"card_prompt": outfit.get("card_prompt"),
"flux_prompt": outfit.get("flux_prompt"),
"card_path": card_path,
"card_url": url,
"card_saved_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
})
save_favorites(favs)
return visual_card_html(url), render_favorites_html(), render_saved_cards_html()
def on_save_recreated_card():
try:
_card_html, favorites_html, saved_cards_html = on_save_outfit_card(0)
return (
"<div class='ct-reasoning ct-reasoning-pink'><strong>Saved.</strong> This recreated outfit card is now in your profile.</div>",
favorites_html,
saved_cards_html,
)
except Exception as ex:
return (
f"<div class='ct-empty' style='color:var(--ct-dpink);text-align:left'>Could not save recreated card: {escape(str(ex))}</div>",
render_favorites_html(),
render_saved_cards_html(),
)
def on_recreate_feedback(action):
history_html, favorites_html, insight_html, analytics_html = on_feedback(0, action)
labels = {
"save": "Saved this recreated look.",
"like": "Marked as wearable.",
"dislike": "Marked as not this.",
"too_formal": "Got it: too formal for this inspiration.",
"too_basic": "Got it: too basic for this inspiration.",
"wrong_vibe": "Got it: wrong vibe for this inspiration.",
}
message = labels.get(action, "Feedback saved.")
return (
f"<div class='ct-reasoning ct-reasoning-pink'><strong>{escape(message)}</strong></div>",
history_html,
favorites_html,
insight_html,
analytics_html,
)
def on_swap_item(idx_s, slot):
global current_recs
try:
idx = int(idx_s or 0)
except Exception:
return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, ""
if not (0 <= idx < len(current_recs)):
return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, ""
wardrobe = [item for item in load_wardrobe() if item_has_image(item)]
wm = {item["id"]: item for item in wardrobe}
outfit = current_recs[idx]
slot = slot or "accessory"
used = {item_id for item_id in outfit.get("items", {}).values() if item_id}
current_id = outfit.get("items", {}).get(slot)
used.discard(current_id)
ref = normalize_reference_details({"description": outfit.get("energy_detected", ""), "style": outfit.get("energy_detected", "")})
choices = best_items_for_slot(wardrobe, ref, slot, used, limit=8)
replacement = next((item for item in choices if item.get("id") != current_id), None)
if replacement:
outfit["items"][slot] = replacement["id"]
if slot == "dress":
outfit["items"]["top"] = None
outfit["items"]["bottom"] = None
elif slot in {"top", "bottom"}:
outfit["items"]["dress"] = None
current_recs[idx] = refresh_outfit(outfit, wm, adjusted=True)
return render_outfits_html(current_recs, wm, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, ""
def on_remove_item(idx_s, slot):
global current_recs
try:
idx = int(idx_s or 0)
except Exception:
return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, ""
if not (0 <= idx < len(current_recs)):
return render_outfits_html([], {}, 0), "Outfit 1 of 0", 0, ""
slot = slot or "accessory"
outfit = current_recs[idx]
if slot in outfit.get("items", {}):
outfit["items"][slot] = None
wm = {item["id"]: item for item in load_wardrobe()}
current_recs[idx] = refresh_outfit(outfit, wm, adjusted=True)
return render_outfits_html(current_recs, wm, idx), f"Outfit {idx + 1} of {len(current_recs)}", idx, ""
def on_feedback(idx_s, action):
global current_recs
try: idx = int(idx_s)
except: return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html()
if not (0 <= idx < len(current_recs)):
return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html()
o = current_recs[idx]
h = load_history()
if action in ("like","dislike","too_formal","too_basic","wrong_vibe"):
os_set = set(o.get("items",{}).values())
for log in reversed(h):
if set(log.get("outfit",{}).get("items",{}).values())==os_set and log.get("rating")=="generated":
log["rating"]=action; break
save_history(h)
elif action=="save":
favs = load_favorites()
os_set = set(o.get("items",{}).values())
if not any(set(f.get("items",{}).values())==os_set for f in favs):
favs.append({"id":str(uuid.uuid4()),"saved_at":datetime.datetime.now().strftime("%Y-%m-%d"),
"name":o.get("name"),"items":o.get("items"),"reasoning":o.get("reasoning"),
"energy_detected":o.get("energy_detected"),"styling_note":o.get("styling_note"),
"card_prompt":o.get("card_prompt"),"flux_prompt":o.get("flux_prompt")})
save_favorites(favs)
return render_history_html(), render_favorites_html(), get_style_profile(), render_analytics_html()
def on_del_fav(fid):
save_favorites([f for f in load_favorites() if f["id"]!=fid]); return render_favorites_html()
def _legacy_on_recreate(path):
if path is None: return "<div class='ct-empty'>Upload an inspiration image to find matching wardrobe twins.</div>"
img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB")
details = {}
if HAS_LOCAL_MODELS:
try: details = extract_attributes(img)
except: pass
if not details or "error" in details:
details = {"category":"top","type":"blazer","color":["beige"],"style":"tailored","description":"a tailored blazer outfit"}
wardrobe = load_wardrobe()
target_colors = [c.lower() for c in (details.get("color",[]) if isinstance(details.get("color"),list) else [details.get("color","")])]
type_d = details.get("type","")
if isinstance(type_d, list):
type_d = type_d[0] if type_d else ""
if not isinstance(type_d, str):
type_d = str(type_d)
type_d = type_d.lower()
scored = []
for item in wardrobe:
s = 0
if item.get("category")==details.get("category"): s+=3
item_type = item.get("type","")
if isinstance(item_type, list):
item_type = " ".join(item_type)
if not isinstance(item_type, str):
item_type = str(item_type)
if type_d and type_d in item_type.lower(): s+=2
item_cols = [c.lower() for c in (item.get("color",[]) if isinstance(item.get("color"),list) else [item.get("color","")])]
if any(c in target_colors for c in item_cols): s+=2
if item.get("style")==details.get("style"): s+=1
if s>1: scored.append((s,item))
scored.sort(key=lambda x:-x[0])
matches = [x[1] for x in scored[:4]]
grid = '<div class="ct-grid">'+"".join(f"""
<div class="ct-polaroid">
<div class="ct-clip"></div>
<div class="ct-photo" style="height:110px"><img src="{img_src(i['id'])}" onerror="this.src='{IMG_FALLBACK}'" alt=""></div>
<div class="ct-caption">{normalize_item_type(i.get('type','item'))}</div>
<div class="ct-meta">{', '.join(i.get('color',[]) if isinstance(i.get('color'),list) else [i.get('color','')])}</div>
</div>""" for i in matches)+"</div>" if matches else "<div class='ct-empty-sm'>No close matches found. Upload more items to improve results.</div>"
return f"""
<div class="ct-card">
<div class="ct-card-title">inspiration analysis</div>
<p style="font-size:.92rem;line-height:1.55;margin-bottom:1rem"><strong>Detected:</strong> {details.get('description','')} · <strong>Colors:</strong> {', '.join(target_colors)} · <strong>Style:</strong> {details.get('style','')}</p>
<div style="border-top:1px dashed var(--ct-pink);padding-top:1rem">
<div class="ct-card-title" style="font-size:1.2rem;border:none;margin:0">closet matches</div>
<p style="font-size:.8rem;color:var(--ct-taupe);margin:.3rem 0 .75rem">Items already in your wardrobe that can recreate this look:</p>
{grid}
</div>
</div>"""
def on_recreate(path, note=""):
global current_recs
if path is None:
return "<div class='ct-empty'>Upload an inspiration image to find matching wardrobe twins.</div>"
img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB")
details = {}
if HAS_LOCAL_MODELS:
try:
details = extract_attributes(img)
except Exception:
details = {}
if not details or "error" in details:
details = analyze_reference_image_fallback(img, note)
ref = normalize_reference_details(details, note)
wardrobe = [item for item in load_wardrobe() if item_has_image(item)]
if not wardrobe:
return "<div class='ct-empty'>Add wardrobe items before recreating a look.</div>"
outfit = build_recreated_outfit(wardrobe, ref)
current_recs = [outfit]
history = load_history()
history.append({
"id": str(uuid.uuid4()),
"timestamp": datetime.datetime.now().isoformat(),
"situation": {
"occasion": "Recreate Look",
"weather": "reference image",
"temp": None,
"mood": ref.get("description", "reference look"),
},
"outfit": outfit,
"rating": "generated",
})
save_history(history)
wm = {item["id"]: item for item in wardrobe}
try:
_card_path, card_url, _count = create_outfit_collage(outfit, wm)
card_html = f"<div class='ct-generated-card' style='text-align:center'><img src='{card_url}' alt='Recreated closet outfit'></div>"
except Exception as ex:
card_html = f"<div class='ct-empty' style='color:var(--ct-dpink)'>Could not create card: {escape(str(ex))}</div>"
relevant_slots = list(ref.get("slots") or [])
if not relevant_slots:
relevant_slots = [slot for slot, item_id in outfit.get("items", {}).items() if item_id]
relevant_slots = [slot for slot in ["dress", "top", "bottom", "outerwear", "shoes", "accessory"] if slot in set(relevant_slots)]
slot_sections = []
for slot in relevant_slots:
matches = best_items_for_slot(wardrobe, ref, slot, set(), limit=3)
if not matches:
continue
mini = "".join(f"""
<div class="ct-polaroid" style="max-width:105px">
<div class="ct-photo" style="height:86px"><img src="{img_src(i['id'])}" onerror="this.src='{IMG_FALLBACK}'" alt=""></div>
<div class="ct-caption" style="font-size:.82rem">{normalize_item_type(i.get('type','item'))}</div>
</div>""" for i in matches)
slot_sections.append(f"""
<div style="margin-bottom:1rem">
<div class="ct-meta" style="margin-bottom:.35rem">{slot} matches</div>
<div style="display:flex;gap:.65rem;flex-wrap:wrap">{mini}</div>
</div>""")
selected_html = render_outfits_html([outfit], wm, 0)
color_text = ", ".join(ref.get("colors") or ["not detected"])
style_text = ", ".join(sorted(ref.get("style_terms") or [])) or "same energy"
gap_html = closet_gap_notes(outfit, ref, wm)
return f"""
<div class="ct-card">
<div class="ct-card-title">recreated from your closet</div>
<p style="font-size:.92rem;line-height:1.55;margin-bottom:1rem"><strong>Detected:</strong> {escape(str(ref.get('description','reference look')))}<br><strong>Energy:</strong> {escape(style_text)}<br><strong>Colors:</strong> {escape(color_text)}</p>
{gap_html}
{card_html}
<div style="border-top:1px dashed var(--ct-pink);padding-top:1rem;margin-top:1rem">
<div class="ct-card-title" style="font-size:1.2rem;border:none;margin:0">wear this</div>
{selected_html}
</div>
<div style="border-top:1px dashed var(--ct-pink);padding-top:1rem;margin-top:1rem">
<div class="ct-card-title" style="font-size:1.2rem;border:none;margin:0">piece-by-piece matches</div>
{''.join(slot_sections)}
</div>
</div>"""
def on_recreate_to_stylist(path, note=""):
if path is None:
return (
"<div class='ct-stylist-empty'><div class='ct-empty-title'>upload an inspiration first</div><p>Add a Pinterest or Instagram screenshot to recreate its energy from your closet.</p></div>",
"Outfit 1 of 0",
0,
"",
)
on_recreate(path, note)
wm = {item["id"]: item for item in load_wardrobe()}
visual_html = ""
if current_recs:
try:
_card_path, card_url, _count = create_outfit_collage(current_recs[0], wm)
visual_html = visual_card_html(card_url)
except Exception:
visual_html = ""
return render_outfits_html(current_recs, wm, 0), f"Outfit 1 of {len(current_recs)}", 0, visual_html
def on_avatar(path):
avatar_path = os.path.join(IMAGES_DIR, "profile_avatar.png")
if path is None:
if fs_exists(avatar_path):
try: os.remove(avatar_path)
except: pass
return render_badge_html()
img = Image.open(path).convert("RGB") if isinstance(path,str) else Image.fromarray(path).convert("RGB")
img.save(avatar_path)
return render_badge_html()
def on_confirm_and_upload(path):
upload_result = on_upload(path)
if path is None:
return (
gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
"", "top", "", "", "none", "casual", "regular", "all", 0.3, "", None,
)
meta_visible, image_id, cat, typ, cols, pat, sty, fit, seasons, formality, desc, preview, processing_hidden = upload_result
return (
gr.update(visible=False),
processing_hidden,
meta_visible,
image_id,
cat,
typ,
cols,
pat,
sty,
fit,
seasons,
formality,
desc,
preview,
)
def on_profile_save(name, bio):
p=load_profile(); p["name"]=name; p["bio"]=bio; save_profile(p)
return render_badge_html(), f"<p style='font-size:.95rem;line-height:1.6;color:var(--ct-brown)'>{get_style_profile()}</p>"
def on_profile_bg(path):
if path is None:
if os.path.exists(PROFILE_BG_PATH):
try:
os.remove(PROFILE_BG_PATH)
except Exception:
pass
return render_badge_html()
img = Image.open(path).convert("RGB") if isinstance(path, str) else Image.fromarray(path).convert("RGB")
img.save(PROFILE_BG_PATH)
return render_badge_html()
# ── Gradio App ─────
# Static assets are mounted on demo.app after the UI is built (see launch block).
# Enhance inspo images to higher-quality variants on startup if present
def _enhance_inspo(src_name, dst_name, scale=1.6):
try:
p = os.path.join(FRONTEND_DIR, 'ui', src_name)
if not os.path.exists(p):
return False
img = Image.open(p).convert('RGB')
w,h = img.size
new = img.resize((int(w*scale), int(h*scale)), resample=Image.LANCZOS)
# sharpen a couple of times gently
for _ in range(2):
new = new.filter(ImageFilter.SHARPEN)
dst = os.path.join(FRONTEND_DIR, 'ui', dst_name)
new.save(dst, quality=92)
return True
except Exception as e:
print('Could not enhance', src_name, e)
return False
# Try to create high-def background variants
_enhance_inspo('inspo7.png', 'inspo7_hd.png', scale=1.4)
_enhance_inspo('inspo6.png', 'inspo6_hd.png', scale=1.6)
FONT_IMPORT = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bodoni+Moda:opsz,wght@6..96,400;6..96,500;6..96,700&family=Sacramento&family=Shrikhand&family=Space+Grotesk:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
body, .gradio-container { font-family: 'Space Grotesk', ui-sans-serif, sans-serif !important; }
</style>
"""
JS_HELPERS = """
window.ctCurrentSlideIndex = 0;
function ctUpdateCarouselIndicator() {
var indicator = document.getElementById('ct-carousel-indicator');
var slides = document.querySelectorAll('.ct-outfit-slide');
if (!indicator || !slides.length) return;
var idx = window.ctCurrentSlideIndex;
if (idx < 0 || idx >= slides.length) idx = 0;
indicator.innerText = 'Outfit ' + (idx + 1) + ' of ' + slides.length;
}
function ctShowSlide(index, selector) {
selector = selector || '.ct-outfit-slide';
var slides = document.querySelectorAll(selector);
if (!slides.length) return;
index = ((index % slides.length) + slides.length) % slides.length;
slides.forEach(function(slide, i) {
slide.style.display = i === index ? 'block' : 'none';
});
window.ctCurrentSlideIndex = index;
if (selector === '.ct-outfit-slide') {
ctUpdateCarouselIndicator();
}
}
function ctShiftCarousel(dir, selector) {
ctShowSlide(window.ctCurrentSlideIndex + Number(dir), selector);
}
window._ctSc = window._ctSc || {};
window.ctScNav = function(uid, total, dir) {
if (!uid) return;
total = Number(total) || 0;
if (total <= 0) return;
var current = Number(window._ctSc[uid] || 0);
var next = ((current + Number(dir)) % total + total) % total;
window._ctSc[uid] = next;
var slides = document.querySelectorAll('#' + uid + ' .ct-sc-slide');
if (slides.length) {
slides.forEach(function(slide, idx) {
slide.style.display = idx === next ? 'block' : 'none';
});
} else {
var curEl = document.getElementById('ct-sc-slide-' + current);
if (curEl) curEl.style.display = 'none';
var nextEl = document.getElementById('ct-sc-slide-' + next);
if (nextEl) nextEl.style.display = 'block';
}
var ctr = document.getElementById(uid + '_ctr');
if (ctr) ctr.textContent = (next + 1) + ' / ' + total;
}
function triggerGradioFeedback(idx, action) {
var idWrapper = document.getElementById('hidden_feedback_id');
var actWrapper = document.getElementById('hidden_feedback_action');
var btnWrapper = document.getElementById('hidden_feedback_btn');
if (!idWrapper || !actWrapper || !btnWrapper) return;
var idInput = idWrapper.querySelector('input, textarea');
var actInput = actWrapper.querySelector('input, textarea');
var button = btnWrapper.querySelector('button, input[type="button"], input[type="submit"]');
if (!idInput || !actInput || !button) return;
idInput.value = idx.toString();
actInput.value = action;
idInput.dispatchEvent(new Event('input', {bubbles:true}));
actInput.dispatchEvent(new Event('input', {bubbles:true}));
setTimeout(function() { button.click(); }, 50);
}
document.addEventListener('click', function(event) {
// CTA nav buttons use data-ct-nav instead of inline onclick
var ctNavTrigger = event.target.closest('[data-ct-nav]');
if (ctNavTrigger) {
event.preventDefault();
var tab = ctNavTrigger.dataset.ctNav;
if (typeof window.ctNavTo === 'function') {
window.ctNavTo(tab);
} else {
console.warn('ctNavTo not defined at click time for tab:', tab);
}
return;
}
var savedNav = event.target.closest('button[data-ct-sc-nav-dir]');
if (savedNav) {
event.preventDefault();
var uid = savedNav.dataset.ctScUid;
var total = Number(savedNav.dataset.ctScTotal) || 0;
var dir = Number(savedNav.dataset.ctScNavDir) || 0;
window.ctScNav(uid, total, dir);
return;
}
var navButton = event.target.closest('button.ct-carousel-button');
if (navButton) {
event.preventDefault();
ctShiftCarousel(navButton.dataset.dir || 0);
return;
}
var feedbackButton = event.target.closest('button.ct-feedback-action');
if (feedbackButton) {
event.preventDefault();
var idx = parseInt(feedbackButton.dataset.idx || '0', 10);
triggerGradioFeedback(idx, feedbackButton.dataset.action);
return;
}
});
window.addEventListener('load', function() {
ctShowSlide(window.ctCurrentSlideIndex || 0);
});
// ── Navigation helper: navigate to a named tab ──────────────────────────────
window.ctFireClick = function(element) {
if (!element) return false;
if (typeof element.click === 'function') {
element.click();
return true;
}
var event = new MouseEvent('click', { bubbles: true, cancelable: true });
return element.dispatchEvent(event);
}
window.ctNavTo = function(tab) {
var pageMap = {
home: 'ct-page-home',
closet: 'ct-page-closet',
stylist: 'ct-page-stylist',
favorites: 'ct-page-favorites',
analytics: 'ct-page-analytics',
profile: 'ct-page-profile'
};
if (pageMap[tab]) {
Object.keys(pageMap).forEach(function(key) {
var page = document.getElementById(pageMap[key]);
if (!page) return;
page.style.display = key === tab ? 'block' : 'none';
page.hidden = key !== tab;
page.setAttribute('aria-hidden', key === tab ? 'false' : 'true');
});
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
var tabLabels = {
home: 'Home',
closet: 'My Closet',
stylist: 'AI Stylist',
recreate: 'Recreate',
favorites: 'Favorites',
analytics: 'Analytics',
profile: 'Profile'
};
var idMap = {
home: 'nav-home',
closet: 'nav-closet',
stylist: 'nav-stylist',
recreate: 'nav-recreate',
favorites: 'nav-favorites',
analytics: 'nav-analytics',
profile: 'nav-profile'
};
var targetLabel = tabLabels[tab];
var targetId = idMap[tab];
if (!targetLabel && !targetId) return;
function synthClick(el) {
if (!el) return false;
try { el.click(); return true; } catch (e) {}
var evs = ['pointerdown','mousedown','pointerup','mouseup','click'];
for (var i = 0; i < evs.length; i++) {
try { el.dispatchEvent(new MouseEvent(evs[i], { bubbles: true, cancelable: true })); } catch (e) {}
}
return true;
}
// 1) Try exact id
if (targetId) {
var wrapper = document.getElementById(targetId);
if (wrapper) {
var btn = wrapper.querySelector('button, [role="button"], input[type="button"], input[type="submit"], a');
if (btn && synthClick(btn)) return;
if (synthClick(wrapper)) return;
}
// try id contains
var candidates = document.querySelectorAll('[id*="' + targetId + '"]');
for (var k = 0; k < candidates.length; k++) {
var c = candidates[k];
var cb = c.querySelector('button, [role="button"], input[type="button"], input[type="submit"], a');
if (cb && synthClick(cb)) return;
if (synthClick(c)) return;
}
}
// 2) Try matching label text
if (targetLabel) {
var norm = targetLabel.trim().toLowerCase();
var els = document.querySelectorAll('button, [role="button"], a, input[type="button"], input[type="submit"]');
for (var m = 0; m < els.length; m++) {
var e = els[m];
var txt = (e.textContent || e.value || '').trim().toLowerCase();
if (!txt) continue;
if (txt === norm || txt.indexOf(norm) !== -1) {
if (synthClick(e)) return;
}
}
}
// 3) fallback to data-nav selector
var fallback = document.querySelector('[data-nav="' + tab + '"] button, [data-nav="' + tab + '"]');
if (fallback) { synthClick(fallback); return; }
console.warn('ctNavTo: could not locate nav target for', tab);
}
"""
NAVBAR_HTML = """
<div class="ct-nav-shell">
<div class="ct-logo"><span>closet twin</span></div>
<div class="ct-nav-shell-links" aria-label="Primary navigation">
<button type="button" data-ct-nav="home">Home</button>
<button type="button" data-ct-nav="closet">My Closet</button>
<button type="button" data-ct-nav="stylist">AI Stylist</button>
<button type="button" data-ct-nav="favorites">Favorites</button>
<button type="button" data-ct-nav="analytics">Analytics</button>
<button type="button" data-ct-nav="profile">Profile</button>
</div>
</div>
"""
FOOTER_HTML = """
<div class="ct-footer">
<div class="ct-footer-title">closet twin — personal ai stylist</div>
<p class="ct-footer-sub">Backyard AI Hackathon Track &nbsp;·&nbsp; Built with Gradio &amp; Flux &nbsp;·&nbsp; Vision by MiniCPM</p>
</div>
"""
print("DEBUG: About to create Gradio theme")
ct_theme = gr.themes.Soft(
primary_hue=gr.themes.colors.rose,
secondary_hue=gr.themes.colors.emerald,
neutral_hue=gr.themes.colors.stone,
font=gr.themes.GoogleFont("Space Grotesk"),
font_mono=gr.themes.GoogleFont("Space Grotesk"),
).set(
body_background_fill="transparent",
body_background_fill_dark="transparent",
background_fill_primary="#FDFBFC",
background_fill_secondary="#FDFBFC",
block_background_fill="transparent",
block_background_fill_dark="transparent",
border_color_primary="rgba(147,5,0,0.22)",
block_border_color="rgba(147,5,0,0.22)",
block_border_width="1px",
block_shadow="0 4px 20px rgba(140,114,101,0.06)",
block_radius="0px",
block_label_background_fill="transparent",
block_label_border_color="transparent",
block_label_shadow="none",
block_title_text_color="#930500",
block_title_text_weight="600",
block_title_text_size="0.9rem",
input_background_fill="#FDFBFC",
input_border_color="rgba(147,5,0,0.28)",
input_border_color_focus="#930500",
input_shadow="none",
input_shadow_focus="0 0 0 3px rgba(179,116,128,0.15)",
input_radius="0px",
button_primary_background_fill="#930500",
button_primary_background_fill_hover="#B10B07",
button_primary_text_color="#ffffff",
button_primary_border_color="transparent",
button_secondary_background_fill="#C9DAEA",
button_secondary_background_fill_hover="#C8DCF6",
button_secondary_text_color="#930500",
button_secondary_border_color="#930500",
button_large_radius="0px",
button_large_padding="0.6rem 1.8rem",
button_small_radius="0px",
slider_color="#930500",
checkbox_background_color="#FDFBFC",
checkbox_border_color="rgba(147,5,0,0.28)",
checkbox_label_background_fill="#FDFBFC",
checkbox_label_background_fill_hover="#C9DAEA",
checkbox_label_border_color="rgba(147,5,0,0.28)",
checkbox_label_text_color="#930500",
table_border_color="rgba(147,5,0,.22)",
panel_background_fill="transparent",
panel_border_color="transparent",
)
print("DEBUG: Gradio theme created successfully")
print("DEBUG: About to create gr.Blocks and build UI")
try:
with gr.Blocks(
title="Closet Twin - AI Wardrobe Assistant",
theme=ct_theme,
css=CUSTOM_CSS,
head=FONT_IMPORT + "<script>" + JS_HELPERS + "</script>",
) as demo:
print("DEBUG: Inside gr.Blocks context")
# Inject fonts and CSS (Gradio 6: theme and head are now passed to launch())
gr.HTML("", elem_id="ct-style-injector")
hidden_feedback_id = gr.Textbox(visible=False, elem_id="hidden_feedback_id")
hidden_feedback_action = gr.Textbox(visible=False, elem_id="hidden_feedback_action")
hidden_feedback_btn = gr.Button(visible=False, elem_id="hidden_feedback_btn")
anchor_state = gr.State("")
style_slide_index = gr.State(0)
with gr.Row(elem_classes="ct-navbar"):
with gr.Row(elem_classes="ct-navbar-inner"):
gr.HTML('<div class="ct-logo-title">closet twin</div>', elem_classes="ct-navbar-logo-wrap")
with gr.Row(elem_classes="ct-nav-links"):
nav_home = gr.Button("Home", elem_id="nav-home", variant="secondary")
nav_closet = gr.Button("My Closet", elem_id="nav-closet", variant="secondary")
nav_stylist = gr.Button("AI Stylist", elem_id="nav-stylist", variant="secondary")
nav_favorites = gr.Button("Favorites", elem_id="nav-favorites", variant="secondary")
nav_analytics = gr.Button("Analytics", elem_id="nav-analytics", variant="secondary")
nav_profile = gr.Button("Profile", elem_id="nav-profile", variant="secondary")
with gr.Column(visible=True, elem_id="ct-page-home", elem_classes="ct-page") as page_home:
landing_out = gr.HTML(value=render_landing_html())
with gr.Row(elem_classes="ct-landing-ctas"):
landing_cta_closet = gr.Button("Open My Closet", elem_id="landing-closet-btn", variant="primary")
landing_cta_stylist = gr.Button("Get Styled Now", elem_id="landing-stylist-btn", variant="secondary")
with gr.Column(visible=False, elem_id="ct-page-closet", elem_classes="ct-page") as page_closet:
gr.HTML('<div class="ct-page-heading">My Digital Wardrobe</div>')
with gr.Row(equal_height=False):
with gr.Column(scale=3, elem_classes="ct-col-flat"):
cat_radio = gr.Radio(choices=["all","top","bottom","shoes","outerwear","accessory","dress"],
value="all", label="Filter by category")
closet_grid = gr.HTML(render_wardrobe_html())
with gr.Column(scale=2, elem_classes="ct-card"):
gr.HTML('<div class="ct-card-title">Add a Garment</div>')
upload_img = gr.Image(type="filepath", label="Upload photo")
confirm_upload_btn = gr.Button("Confirm Adding Outfit", variant="primary", visible=False)
processing_html = gr.HTML("<div class='ct-progress-wrapper'><span class='ct-progress-label'>Processing uploaded image...</span><div class='ct-progress'><div class='ct-progress-bar'></div></div></div>", visible=False)
with gr.Column(visible=False) as meta_col:
gr.HTML('<p style="font-size:.82rem;color:var(--ct-taupe);margin:.5rem 0">Review AI-extracted attributes:</p>')
tmp_id = gr.Textbox(visible=False)
new_cat = gr.Dropdown(choices=["top","bottom","shoes","outerwear","accessory","dress"], label="Category")
new_type = gr.Textbox(label="Type")
new_cols = gr.Textbox(label="Colors (comma-separated)")
new_pat = gr.Textbox(label="Pattern")
new_sty = gr.Textbox(label="Style")
new_fit = gr.Dropdown(choices=["oversized","slim","regular","unknown"], label="Fit")
new_seas = gr.Textbox(label="Seasons (comma-separated)")
new_form = gr.Slider(0, 1, .3, step=.05, label="Formality (0=casual, 1=formal)")
new_desc = gr.TextArea(label="Description")
new_prev = gr.Image(label="Processed image", height=180)
closet_save_btn = gr.Button("Save to Wardrobe", variant="primary")
with gr.Column(visible=False, elem_id="ct-page-stylist", elem_classes="ct-page") as page_stylist:
gr.HTML('<div class="ct-card-title" style="text-align:center;border:none;font-size:7rem;margin-bottom:1rem">what are we dressing for?</div>')
with gr.Row(equal_height=False):
with gr.Column(scale=2):
with gr.Column(elem_classes="ct-card"):
with gr.Tabs():
with gr.Tab("Describe the vibe"):
occ = gr.Dropdown(["University Presentation","Coffee with Friends","Wedding","Family Dinner","Casual Weekend","Job Interview","Beach Outing"], value="Coffee with Friends", label="Occasion")
with gr.Row():
wth = gr.Dropdown(["Sunny","Rainy","Snowy","Windy","Overcast"], value="Sunny", label="Weather")
tmp = gr.Slider(-10, 40, 22, step=1, label="Temp (°C)")
mood = gr.Dropdown(["Casual","Elegant","Romantic","Cozy","Bold"], value="Casual", label="Mood")
pref = gr.Textbox(label="Optional preferences", placeholder="e.g. layers, pastel tones...")
gr.HTML("<div style='border-top:1px dashed rgba(147,5,0,.22);margin:1rem 0 .75rem;padding-top:.75rem'><strong style='font-size:.95rem;font-family:Bodoni Moda,serif;color:var(--ct-brown)'>style around one piece</strong><p style='font-size:.78rem;color:var(--ct-taupe);margin:.25rem 0 0.5rem'>Optional: choose a garment from your wardrobe.</p></div>")
wear_choice = gr.Dropdown(choices=item_choices(), label="Choose an item to wear", interactive=True)
anchor_prev = gr.HTML("")
clear_btn = gr.Button("Clear Item", variant="secondary", visible=False)
gen_btn = gr.Button("Generate Outfit Ideas", variant="primary")
with gr.Tab("Use inspiration"):
gr.HTML('<p style="font-size:.78rem;color:var(--ct-taupe);margin-bottom:0.5rem">Drop a Pinterest or Instagram screenshot to recreate the energy.</p>')
stylist_inspo_img = gr.Image(type="filepath", label="Pinterest / Instagram screenshot")
stylist_inspo_note = gr.Textbox(label="Optional note", placeholder="olive tank, sage wide-leg pants, brown sandals")
stylist_inspo_btn = gr.Button("Recreate This Energy", variant="primary")
with gr.Column(scale=3, elem_classes="ct-col-flat"):
gr.HTML('<div class="ct-card-title">AI Recommendations</div>')
outfits_out = gr.HTML(render_outfits_html([], {}, 0))
with gr.Row():
prev_outfit = gr.Button('↩ Previous', variant='secondary')
next_outfit = gr.Button('Next ↪', variant='secondary')
with gr.Row():
like_btn = gr.Button('Wear this', variant='primary')
dislike_btn = gr.Button('Not this', variant='secondary')
stylist_save_btn = gr.Button('Save look', variant='secondary')
with gr.Row():
too_formal_btn = gr.Button('Too formal', variant='secondary')
too_basic_btn = gr.Button('Too basic', variant='secondary')
wrong_vibe_btn = gr.Button('Wrong vibe', variant='secondary')
with gr.Column(elem_classes="ct-slot-tools"):
gr.HTML('<div class="ct-slot-tools-title">adjust one item</div>')
slot_buttons = {}
for slot_name in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]:
with gr.Row(elem_classes="ct-slot-row"):
gr.HTML(f'<div class="ct-slot-label">{slot_name}</div>')
slot_buttons[(slot_name, "swap")] = gr.Button("↻", variant="secondary", elem_classes="ct-icon-button")
slot_buttons[(slot_name, "remove")] = gr.Button("x", variant="secondary", elem_classes="ct-icon-button")
gr.HTML('<div class="ct-slot-tools-title" style="margin-top:1rem">outfit card</div>')
with gr.Row():
visual_card_btn = gr.Button('Generate Card', variant='secondary')
save_card_btn = gr.Button('Save Card to Profile', variant='primary')
visual_card_out = gr.HTML("")
carousel_status = gr.HTML('<div style="margin-top:0.85rem; color:var(--ct-taupe); font-size:0.9rem;">Outfit 1 of 0</div>')
with gr.Column(visible=False, elem_id="ct-page-favorites", elem_classes="ct-page") as page_favorites:
with gr.Row():
with gr.Column(scale=3):
gr.HTML('<div class="ct-card-title">saved outfits</div>')
favs_out = gr.HTML(render_favorites_html())
with gr.Column(scale=2):
with gr.Accordion("Styling History", open=False):
hist_out = gr.HTML(render_history_html())
with gr.Column(visible=False, elem_id="ct-page-analytics", elem_classes="ct-page") as page_analytics:
gr.HTML('<div class="ct-card-title">wardrobe insights</div>')
analytics_out = gr.HTML(render_analytics_html())
with gr.Column(visible=False, elem_id="ct-page-profile", elem_classes="ct-page") as page_profile:
badge_out = gr.HTML(render_badge_html())
gr.HTML("<div style='font-family:Bodoni Moda,serif;font-size:1.5rem;font-weight:700;color:#930500;border-bottom:1px dashed rgba(147,5,0,.22);padding-bottom:.5rem;margin-bottom:1.5rem;'>Edit Profile</div>")
with gr.Row():
with gr.Column(scale=1, elem_classes="ct-card"):
pname = gr.Textbox(label="Name", value=load_profile().get("name",""))
pbio = gr.TextArea(label="Style bio", value=load_profile().get("bio",""))
with gr.Accordion("Update Images", open=False):
pavat = gr.Image(type="filepath", label="Profile photo (avatar)")
gr.HTML('<p style="font-size:.78rem;color:var(--ct-taupe);margin:.25rem 0 .5rem;font-style:italic;">Upload a custom banner background for your profile card:</p>')
pbanner = gr.Image(type="filepath", label="Banner background image")
psave = gr.Button("Save Profile", variant="primary")
with gr.Column(scale=1, elem_classes="ct-card"):
gr.HTML("<div style='font-family:Sacramento,cursive;font-size:1.4rem;color:#930500;margin-bottom:.75rem;'>Stylist Insight</div>")
insight_out = gr.HTML(f"<p style='font-size:.95rem;line-height:1.7;color:#930500'>{get_style_profile()}</p>")
with gr.Column(elem_classes="ct-card", elem_id="ct-saved-cards-section"):
gr.HTML("<div style='font-family:Bodoni Moda,serif;font-size:1.3rem;font-weight:700;color:#930500;margin-bottom:1rem'>saved outfit cards</div>")
saved_cards_idx = gr.State(0)
saved_cards_out = gr.HTML(render_saved_cards_html(0))
with gr.Row():
prev_saved_btn = gr.Button('↩ prev', variant='secondary')
next_saved_btn = gr.Button('next ↪', variant='secondary')
gr.HTML(FOOTER_HTML)
ALL_PAGES = [page_home, page_closet, page_stylist, page_favorites, page_analytics, page_profile]
nav_home.click(lambda: switch_tab('home'), [], ALL_PAGES)
nav_closet.click(lambda: switch_tab('closet'), [], ALL_PAGES)
nav_stylist.click(lambda: switch_tab('stylist'), [], ALL_PAGES)
nav_favorites.click(lambda: switch_tab('favorites'), [], ALL_PAGES)
nav_analytics.click(lambda: switch_tab('analytics'), [], ALL_PAGES)
nav_profile.click(lambda: switch_tab('profile'), [], ALL_PAGES)
try:
landing_cta_closet.click(lambda: switch_tab('closet'), [], ALL_PAGES)
landing_cta_stylist.click(lambda: switch_tab('stylist'), [], ALL_PAGES)
except Exception:
# If variables not in scope for any reason, ignore quietly
pass
# Saved cards server-side navigation
try:
def on_saved_cards_nav(direction, current_idx):
cards = [fav for fav in load_favorites() if fav.get('card_url')]
if not cards:
return render_saved_cards_html(0), 0
total = len(cards)
idx = int(current_idx or 0)
idx = (idx + int(direction)) % total
if idx < 0: idx += total
return render_saved_cards_html(idx), idx
prev_saved_btn.click(lambda i: on_saved_cards_nav(-1, i), [saved_cards_idx], [saved_cards_out, saved_cards_idx])
next_saved_btn.click(lambda i: on_saved_cards_nav(1, i), [saved_cards_idx], [saved_cards_out, saved_cards_idx])
except Exception:
pass
cat_radio.change(on_category, [cat_radio, anchor_state], [closet_grid])
wear_choice.change(on_anchor_select, [wear_choice, cat_radio], [anchor_state, anchor_prev, clear_btn, closet_grid])
def on_image_change(path):
if path is None:
return gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False)
upload_img.change(
on_image_change,
inputs=[upload_img],
outputs=[confirm_upload_btn, meta_col, processing_html]
)
confirm_upload_btn.click(
on_confirm_and_upload,
inputs=[upload_img],
outputs=[
confirm_upload_btn, processing_html, meta_col,
tmp_id, new_cat, new_type, new_cols, new_pat,
new_sty, new_fit, new_seas, new_form, new_desc, new_prev
]
)
closet_save_btn.click(on_save,
[tmp_id, new_cat, new_type, new_cols, new_pat, new_sty, new_fit, new_seas, new_form, new_desc, cat_radio],
[closet_grid, landing_out, wear_choice, analytics_out, meta_col, upload_img])
clear_btn.click(lambda category: ("", "<div class='ct-empty'>No item selected.</div>", gr.update(visible=False), render_wardrobe_html(category, None), gr.update(choices=item_choices(), value=None)), [cat_radio], [anchor_state, anchor_prev, clear_btn, closet_grid, wear_choice])
gen_btn.click(on_style, [occ, wth, tmp, mood, pref, anchor_state], [outfits_out, carousel_status, style_slide_index, visual_card_out])
stylist_inspo_btn.click(
on_recreate_to_stylist,
[stylist_inspo_img, stylist_inspo_note],
[outfits_out, carousel_status, style_slide_index, visual_card_out],
)
prev_outfit.click(lambda idx: on_style_slide(-1, idx), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out])
next_outfit.click(lambda idx: on_style_slide(1, idx), [style_slide_index], [outfits_out, carousel_status, style_slide_index, visual_card_out])
like_btn.click(lambda idx: on_feedback(idx, 'like'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
dislike_btn.click(lambda idx: on_feedback(idx, 'dislike'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
stylist_save_btn.click(lambda idx: on_feedback(idx, 'save'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
too_formal_btn.click(lambda idx: on_feedback(idx, 'too_formal'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
too_basic_btn.click(lambda idx: on_feedback(idx, 'too_basic'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
wrong_vibe_btn.click(lambda idx: on_feedback(idx, 'wrong_vibe'), [style_slide_index], [hist_out, favs_out, insight_out, analytics_out])
for slot_name in ["dress", "top", "bottom", "shoes", "outerwear", "accessory"]:
slot_buttons[(slot_name, "swap")].click(
lambda idx, slot=slot_name: on_swap_item(idx, slot),
[style_slide_index],
[outfits_out, carousel_status, style_slide_index, visual_card_out],
)
slot_buttons[(slot_name, "remove")].click(
lambda idx, slot=slot_name: on_remove_item(idx, slot),
[style_slide_index],
[outfits_out, carousel_status, style_slide_index, visual_card_out],
)
visual_card_btn.click(on_generate_visual_card, [style_slide_index], [visual_card_out])
save_card_btn.click(on_save_outfit_card, [style_slide_index], [visual_card_out, favs_out, saved_cards_out])
hidden_feedback_btn.click(on_feedback, [hidden_feedback_id, hidden_feedback_action],
[hist_out, favs_out, insight_out, analytics_out])
pavat.change(on_avatar, pavat, badge_out)
pbanner.change(on_profile_bg, pbanner, badge_out)
psave.click(on_profile_save, [pname, pbio], [badge_out, insight_out])
except Exception as e:
print(f"ERROR building Gradio UI: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
raise
# Detect if running on HuggingFace Spaces
SPACE_ENV_VARS = ["SPACE_ID", "HUGGINGFACE_SPACE_ID", "HF_SPACE_ID"]
IS_SPACES = any(os.getenv(v) for v in SPACE_ENV_VARS)
print(f"DEBUG: IS_SPACES = {IS_SPACES}")
print("DEBUG: About to run launch block")
demo.allowed_paths = [FRONTEND_DIR]
print(f"DEBUG: allowed_paths = {demo.allowed_paths}")
if __name__ == "__main__":
try:
server_name = "0.0.0.0" if IS_SPACES else os.getenv("GRADIO_SERVER_NAME", "127.0.0.1")
server_port = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or "7860")
print(f"DEBUG: Launching Gradio on {server_name}:{server_port} (spaces={IS_SPACES})")
demo.launch(
server_name=server_name,
server_port=server_port,
allowed_paths=[FRONTEND_DIR],
show_error=True,
)
except Exception as e:
print(f"ERROR during demo.launch: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
raise
else:
print("DEBUG: HF Spaces will import `demo` and launch it automatically")
print("DEBUG: All initialization complete!")