Spaces:
Sleeping
Sleeping
| 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 — 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 — 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 · Built with Gradio & Flux · 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!") |