| import hashlib |
| import json |
| import math |
| import os |
| import random |
| import re |
| import urllib.error |
| import urllib.request |
| import wave |
| from dataclasses import asdict, dataclass |
| from html import escape |
| from pathlib import Path |
|
|
| import gradio as gr |
|
|
| try: |
| from huggingface_hub import InferenceClient |
| except Exception: |
| InferenceClient = None |
|
|
|
|
| APP_TITLE = "Cloud Parade Cabinet" |
| CLOUD_MODEL = os.getenv("PARADE_MODEL", "Qwen/Qwen2.5-7B-Instruct") |
| OPENBMB_MODEL = os.getenv("OPENBMB_MODEL", "openbmb/MiniCPM4-8B") |
| NVIDIA_MODEL = os.getenv("NVIDIA_MODEL", "nvidia/llama-3.1-nemotron-nano-8b-v1") |
| NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions" |
| SPACE_URL = os.getenv("PARADE_SPACE_URL", "https://huggingface.co/spaces/build-small-hackathon/cloud-parade-cabinet") |
| MAX_NEW_TOKENS = int(os.getenv("PARADE_MAX_NEW_TOKENS", "180")) |
|
|
| PROVIDERS = [ |
| "Hugging Face: Qwen 7B", |
| "OpenBMB: MiniCPM4 8B", |
| "NVIDIA: Nemotron Nano 8B", |
| "Practice writer", |
| ] |
|
|
| PUBLIC_PROVIDERS = { |
| "Hugging Face: Qwen 7B": "Cloud parade voice", |
| "OpenBMB: MiniCPM4 8B": "Mini cabinet voice", |
| "NVIDIA: Nemotron Nano 8B": "Brass cabinet voice", |
| "Practice writer": "Cabinet practice voice", |
| } |
|
|
| PROVIDER_CHOICES = [ |
| ("Cloud parade voice", "Hugging Face: Qwen 7B"), |
| ("Mini cabinet voice", "OpenBMB: MiniCPM4 8B"), |
| ("Brass cabinet voice", "NVIDIA: Nemotron Nano 8B"), |
| ("Cabinet practice voice", "Practice writer"), |
| ] |
|
|
| WEATHERS = [ |
| "paper rain that apologizes", |
| "sideways sunshine", |
| "fog shaped like old applause", |
| "tiny hailstones with opinions", |
| "moonlight stuck in traffic", |
| ] |
|
|
| MARSHALS = [ |
| "a nervous umbrella", |
| "a brass thimble", |
| "the mayor's missing shoe", |
| "a lantern with stage fright", |
| "a soup spoon in formal gloves", |
| ] |
|
|
| TOWNS = [ |
| "Turnip Junction", |
| "Little Static", |
| "Button-on-the-Hill", |
| "North Crumb", |
| "The Fourth Drawer", |
| ] |
|
|
| TROUBLES = [ |
| "the parade route forgot its own corners", |
| "all floats must travel backward for one block", |
| "the crowd only cheers in whispers", |
| "confetti is legally considered weather", |
| "the final float refuses to be last", |
| ] |
|
|
| COLORS = { |
| "ticket yellow": "#fff2bd", |
| "mint night": "#0f5b52", |
| "tomato band": "#c9513f", |
| "ink blue": "#223f6c", |
| } |
|
|
| LOG_PATH = Path(__file__).resolve().parent / "PARADE_LOG.md" |
| MEDIA_DIR = Path(__file__).resolve().parent / "media" |
|
|
|
|
| @dataclass(frozen=True) |
| class ParadeRequest: |
| town: str |
| weather: str |
| marshal: str |
| trouble: str |
| color: str = "ticket yellow" |
| energy: int = 4 |
| provider: str = PROVIDERS[0] |
| cloud_mode: bool = True |
|
|
|
|
| def clean_text(value: str) -> str: |
| return re.sub(r"\s+", " ", str(value or "")).strip() |
|
|
|
|
| def seed_for(req: ParadeRequest) -> int: |
| raw = json.dumps(asdict(req), sort_keys=True) |
| return int(hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12], 16) |
|
|
|
|
| def prompt_for(req: ParadeRequest) -> str: |
| return f""" |
| Write a miniature parade plan for a strange toy called Cloud Parade Cabinet. |
| Keep it under 120 words. |
| Use crisp, playful, human-readable copy. |
| The parade must have: |
| - a parade title |
| - three floats with short names and one visible action each |
| - one crowd chant in quotes |
| - a finale that changes the route |
| |
| Town: {req.town} |
| Weather: {req.weather} |
| Grand marshal: {req.marshal} |
| Trouble: {req.trouble} |
| Energy: {req.energy}/5 |
| """.strip() |
|
|
|
|
| def public_provider(provider: str) -> str: |
| return PUBLIC_PROVIDERS.get(provider, provider) |
|
|
|
|
| def public_mode(trace: dict[str, object]) -> str: |
| return "Live" if trace.get("mode") == "cloud" else "Practice" |
|
|
|
|
| def fallback_parade(req: ParadeRequest) -> str: |
| rng = random.Random(seed_for(req)) |
| verbs = ["wobbles", "bows", "zigzags", "sparkles", "argues politely", "turns left twice"] |
| float_names = [ |
| f"{req.marshal.title()} Baton Cart", |
| f"{req.weather.title()} Wagon", |
| f"{rng.choice(['Pocket Drum', 'Lantern Choir', 'Button Brigade', 'Crumb Engine'])}", |
| ] |
| chant = rng.choice( |
| [ |
| "Left foot, cloud foot, cabinet door!", |
| "Tiny street, louder feet!", |
| "Bring the corner back!", |
| "No float left behind!", |
| ] |
| ) |
| finale = rng.choice( |
| [ |
| "The route folds into a postcard and opens one block east.", |
| "The last float becomes the first and the crowd follows the correction.", |
| "A chalk arrow sneezes, sending everyone through the narrowest alley.", |
| ] |
| ) |
| return ( |
| f"{req.town} Cloud Parade. " |
| f"Float 1: {float_names[0]} {rng.choice(verbs)} under {req.weather}. " |
| f"Float 2: {float_names[1]} {rng.choice(verbs)} while {req.trouble}. " |
| f"Float 3: {float_names[2]} {rng.choice(verbs)} beside the curb. " |
| f'The crowd chants "{chant}" ' |
| f"Finale: {finale}" |
| ) |
|
|
|
|
| def call_cloud_parade(req: ParadeRequest) -> tuple[str, dict[str, object]]: |
| trace = {"mode": "fallback", "provider": req.provider, "model": None, "error": None} |
| if not req.cloud_mode or req.provider == "Practice writer": |
| trace["error"] = "live writer disabled" |
| return fallback_parade(req), trace |
| if req.provider == "NVIDIA: Nemotron Nano 8B": |
| return call_nvidia_parade(req, trace) |
| return call_hf_parade(req, trace) |
|
|
|
|
| def call_hf_parade(req: ParadeRequest, trace: dict[str, object]) -> tuple[str, dict[str, object]]: |
| token = os.getenv("HF_API_KEY") or os.getenv("HF_TOKEN") |
| model = OPENBMB_MODEL if req.provider == "OpenBMB: MiniCPM4 8B" else CLOUD_MODEL |
| if not token: |
| trace["error"] = "HF_API_KEY or HF_TOKEN is not set" |
| return fallback_parade(req), trace |
| if InferenceClient is None: |
| trace["error"] = "huggingface_hub InferenceClient is unavailable" |
| return fallback_parade(req), trace |
| try: |
| client = InferenceClient(api_key=token) |
| response = client.chat_completion( |
| model=model, |
| messages=[ |
| {"role": "system", "content": "You write tiny, strange, delightful toy text. Avoid explaining yourself."}, |
| {"role": "user", "content": prompt_for(req)}, |
| ], |
| max_tokens=MAX_NEW_TOKENS, |
| temperature=0.92, |
| top_p=0.9, |
| ) |
| text = clean_text(response.choices[0].message.content) |
| if not text: |
| raise RuntimeError("empty cloud response") |
| trace.update({"mode": "cloud", "model": model, "error": None}) |
| return text, trace |
| except Exception as exc: |
| trace["error"] = str(exc)[:220] |
| return fallback_parade(req), trace |
|
|
|
|
| def call_nvidia_parade(req: ParadeRequest, trace: dict[str, object]) -> tuple[str, dict[str, object]]: |
| token = os.getenv("NVIDIA_API_KEY") |
| if not token: |
| trace["error"] = "NVIDIA_API_KEY is not set" |
| return fallback_parade(req), trace |
| payload = { |
| "model": NVIDIA_MODEL, |
| "messages": [ |
| {"role": "system", "content": "You write tiny, strange, delightful toy text. Avoid explaining yourself."}, |
| {"role": "user", "content": prompt_for(req)}, |
| ], |
| "max_tokens": MAX_NEW_TOKENS, |
| "temperature": 0.85, |
| "top_p": 0.9, |
| "stream": False, |
| } |
| request = urllib.request.Request( |
| NVIDIA_API_URL, |
| data=json.dumps(payload).encode("utf-8"), |
| headers={ |
| "Authorization": f"Bearer {token}", |
| "Content-Type": "application/json", |
| "Accept": "application/json", |
| }, |
| method="POST", |
| ) |
| try: |
| with urllib.request.urlopen(request, timeout=45) as response: |
| data = json.loads(response.read().decode("utf-8")) |
| text = clean_text(data["choices"][0]["message"]["content"]) |
| if not text: |
| raise RuntimeError("empty NVIDIA response") |
| trace.update({"mode": "cloud", "model": NVIDIA_MODEL, "error": None}) |
| return text, trace |
| except (urllib.error.HTTPError, urllib.error.URLError, KeyError, IndexError, json.JSONDecodeError, TimeoutError, RuntimeError) as exc: |
| trace["error"] = str(exc)[:220] |
| return fallback_parade(req), trace |
|
|
|
|
| def split_floats(plan: str) -> list[str]: |
| normalized = re.sub(r"\*\*", "", plan) |
| parts = re.split(r"(?i)(?:\bFloat\s*)?\b[123]\s*[\).:-]\s*", normalized) |
| floats = [] |
| for part in parts[1:]: |
| cleaned = clean_text(part) |
| cleaned = re.split(r"(?i)\b(?:The crowd chants|Crowd chant|Finale)\b", cleaned)[0].strip(" :-") |
| if cleaned: |
| floats.append(cleaned) |
| if len(floats) == 3: |
| break |
| if not floats: |
| parts = re.split(r"(?i)\bFloat\s*\d\s*:\s*", normalized) |
| floats = [clean_text(part) for part in parts[1:4]] |
| while len(floats) < 3: |
| floats.append(["Pocket Drum salutes.", "Lantern Choir glows.", "Crumb Engine turns left."][len(floats)]) |
| return [item[:150] for item in floats] |
|
|
|
|
| def extract_title(plan: str, req: ParadeRequest) -> str: |
| first = clean_text(re.sub(r"[*#`]", "", plan)).split(".")[0].strip('" ') |
| first = re.sub(r"(?i)^title\s*:\s*", "", first).strip() |
| if 8 <= len(first) <= 72: |
| return first |
| return f"{req.town} Cloud Parade" |
|
|
|
|
| def extract_chant(plan: str) -> str: |
| match = re.search(r'"([^"]{4,90})"', plan) |
| if match: |
| return match.group(1) |
| match = re.search(r"(?i)chant\s*[:\-]\s*([^\.]{4,90})", plan) |
| if match: |
| return clean_text(match.group(1)).strip('" ') |
| return "Tiny street, louder feet!" |
|
|
|
|
| def route_points(req: ParadeRequest) -> list[tuple[int, int]]: |
| rng = random.Random(seed_for(req)) |
| points = [(90, 310)] |
| x, y = points[0] |
| for _ in range(4): |
| x += rng.randint(120, 190) |
| y += rng.choice([-70, -35, 35, 70]) |
| y = max(115, min(395, y)) |
| points.append((x, y)) |
| return points |
|
|
|
|
| def parade_html(req: ParadeRequest, plan: str, trace: dict[str, object]) -> str: |
| color = COLORS.get(req.color, COLORS["ticket yellow"]) |
| dark = "#12221f" if req.color != "ticket yellow" else "#fff8df" |
| ink = "#fff8df" if req.color != "ticket yellow" else "#1d2421" |
| points = route_points(req) |
| route = " ".join(f"{x},{y}" for x, y in points) |
| floats = split_floats(plan) |
| title = extract_title(plan, req) |
| chant = extract_chant(plan) |
| float_cards = "".join( |
| f""" |
| <article> |
| <span>Float {index}</span> |
| <strong>{escape(item.split('.')[0])}</strong> |
| <p>{escape(item)}</p> |
| </article> |
| """ |
| for index, item in enumerate(floats, 1) |
| ) |
| steps = "".join( |
| f'<i style="left:{x}px; top:{y}px; animation-delay:{index * 220}ms;"></i>' |
| for index, (x, y) in enumerate(points) |
| ) |
| provider = str(trace.get("provider") or req.provider) |
| return f""" |
| <section class="parade-cabinet" style="--cabinet:{color}; --cabinet-ink:{ink}; --cabinet-bg:{dark};"> |
| <header> |
| <span>{escape(public_mode(trace))} parade / {escape(public_provider(provider))}</span> |
| <h2>{escape(title)}</h2> |
| <p>{escape(req.town)} / {escape(req.weather)} / led by {escape(req.marshal)}</p> |
| </header> |
| <div class="route-stage" aria-label="Animated parade route"> |
| <svg viewBox="0 0 900 470" role="img"> |
| <rect x="34" y="42" width="832" height="382" rx="14"></rect> |
| <polyline points="{route}"></polyline> |
| <text x="70" y="82">Route Cabinet</text> |
| <text x="620" y="405">Final corner</text> |
| </svg> |
| <b class="sun"></b> |
| <div class="route-steps">{steps}</div> |
| <div class="float one">1</div> |
| <div class="float two">2</div> |
| <div class="float three">3</div> |
| </div> |
| <div class="float-grid">{float_cards}</div> |
| <footer> |
| <strong>Crowd chant</strong> |
| <p>"{escape(chant)}"</p> |
| </footer> |
| </section> |
| """ |
|
|
|
|
| def poster_html(req: ParadeRequest, plan: str, trace: dict[str, object]) -> str: |
| title = extract_title(plan, req) |
| chant = extract_chant(plan) |
| return f""" |
| <section class="poster-card"> |
| <span>Share Poster</span> |
| <h2>{escape(title)}</h2> |
| <p>{escape(req.weather)} in {escape(req.town)}. Grand marshal: {escape(req.marshal)}.</p> |
| <strong>"{escape(chant)}"</strong> |
| <em>{escape(public_mode(trace))} parade / {escape(public_provider(req.provider))}</em> |
| </section> |
| """ |
|
|
|
|
| def caption_for(req: ParadeRequest, plan: str) -> str: |
| title = extract_title(plan, req) |
| chant = extract_chant(plan) |
| return ( |
| f"{APP_TITLE}: {title}. {req.marshal} leads {req.weather} through {req.town}. " |
| f"Chant: \"{chant}\" #BuildSmallHackathon #Gradio" |
| )[:280] |
|
|
|
|
| def render_parade_audio(req: ParadeRequest, plan: str) -> str: |
| MEDIA_DIR.mkdir(exist_ok=True) |
| title = extract_title(plan, req) |
| chant = extract_chant(plan) |
| digest = hashlib.sha256(f"{seed_for(req)}::{title}::{chant}".encode("utf-8")).hexdigest()[:12] |
| path = MEDIA_DIR / f"parade_{digest}.wav" |
| if path.exists(): |
| return str(path) |
|
|
| sample_rate = 22050 |
| duration = 5.4 |
| total = int(sample_rate * duration) |
| rng = random.Random(int(digest, 16)) |
| base_notes = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25] |
| melody = [rng.choice(base_notes) * rng.choice([0.75, 1.0, 1.25]) for _ in range(12)] |
| energy = max(1, min(5, int(req.energy))) |
| beat_gap = max(0.18, 0.42 - energy * 0.035) |
|
|
| frames = bytearray() |
| for index in range(total): |
| t = index / sample_rate |
| step_phase = (t % beat_gap) / beat_gap |
| drum = math.exp(-step_phase * 18.0) * 0.42 |
| drum *= math.sin(2 * math.pi * (78 + energy * 9) * t) |
|
|
| note_index = min(len(melody) - 1, int(t / duration * len(melody))) |
| note = melody[note_index] |
| note_phase = (t % (duration / len(melody))) / (duration / len(melody)) |
| bell_env = max(0.0, 1.0 - note_phase) ** 2.4 |
| bell = bell_env * 0.26 * ( |
| math.sin(2 * math.pi * note * t) + 0.42 * math.sin(2 * math.pi * note * 2.01 * t) |
| ) |
|
|
| crowd = 0.035 * math.sin(2 * math.pi * (rng.choice([5.2, 6.1, 7.3])) * t) |
| sample = max(-0.95, min(0.95, drum + bell + crowd)) |
| value = int(sample * 32767) |
| frames.extend(value.to_bytes(2, "little", signed=True)) |
|
|
| with wave.open(str(path), "wb") as wav: |
| wav.setnchannels(1) |
| wav.setsampwidth(2) |
| wav.setframerate(sample_rate) |
| wav.writeframes(frames) |
| return str(path) |
|
|
|
|
| def log_entry(req: ParadeRequest, plan: str, trace: dict[str, object]) -> str: |
| return "\n".join( |
| [ |
| "## Parade Run", |
| f"- town: {req.town}", |
| f"- weather: {req.weather}", |
| f"- marshal: {req.marshal}", |
| f"- trouble: {req.trouble}", |
| f"- run: {public_mode(trace)}", |
| f"- writer: {public_provider(str(trace.get('provider') or req.provider))}", |
| f"- title: {extract_title(plan, req)}", |
| ] |
| ) |
|
|
|
|
| def build_parade(town, weather, marshal, trouble, color, energy, provider, cloud_mode, history): |
| req = ParadeRequest( |
| town=town, |
| weather=weather, |
| marshal=marshal, |
| trouble=trouble, |
| color=color, |
| energy=int(energy), |
| provider=provider, |
| cloud_mode=bool(cloud_mode), |
| ) |
| plan, trace = call_cloud_parade(req) |
| history = list(history or []) |
| history.append({"request": asdict(req), "trace": trace, "title": extract_title(plan, req), "plan": plan}) |
| history = history[-6:] |
| return ( |
| parade_html(req, plan, trace), |
| poster_html(req, plan, trace), |
| render_parade_audio(req, plan), |
| caption_for(req, plan), |
| "\n\n".join(log_entry(req, item["plan"], item["trace"]) for item in reversed(history)), |
| history, |
| trace, |
| ) |
|
|
|
|
| def random_setup(): |
| rng = random.SystemRandom() |
| return ( |
| rng.choice(TOWNS), |
| rng.choice(WEATHERS), |
| rng.choice(MARSHALS), |
| rng.choice(TROUBLES), |
| rng.choice(list(COLORS)), |
| rng.randint(2, 5), |
| ) |
|
|
|
|
| CSS = """ |
| :root { |
| --ink: #1d2421; |
| --paper: #fff8df; |
| --mint: #0f5b52; |
| --clay: #c9513f; |
| --gold: #f1b84b; |
| --blue: #223f6c; |
| } |
| |
| .gradio-container { |
| background: |
| linear-gradient(135deg, rgba(15, 91, 82, 0.14), transparent 36%), |
| linear-gradient(315deg, rgba(201, 81, 63, 0.12), transparent 30%), |
| var(--paper); |
| color: var(--ink) !important; |
| } |
| |
| #cloud-parade-shell { |
| max-width: 1180px; |
| margin: 0 auto; |
| } |
| |
| #cloud-parade-title h1 { |
| margin-bottom: 0; |
| color: var(--ink); |
| font-size: clamp(2rem, 4vw, 4.2rem); |
| letter-spacing: 0; |
| } |
| |
| #cloud-parade-title p { |
| max-width: 760px; |
| color: #31524c; |
| font-weight: 800; |
| } |
| |
| .control-card { |
| position: sticky; |
| top: 12px; |
| padding: 14px; |
| border-radius: 8px; |
| border: 2px solid rgba(29, 36, 33, 0.16); |
| background: #242528; |
| color: #fff8df; |
| } |
| |
| .control-card label, |
| .control-card .label-wrap, |
| .control-card button, |
| .control-card button span { |
| color: #fff8df !important; |
| } |
| |
| .control-card textarea, |
| .control-card input, |
| .control-card [role="textbox"] { |
| color: #fff8df !important; |
| } |
| |
| .parade-cabinet { |
| border: 2px solid var(--ink); |
| border-radius: 8px; |
| overflow: hidden; |
| background: var(--cabinet-bg); |
| color: var(--cabinet-ink); |
| box-shadow: 0 22px 48px rgba(29, 36, 33, 0.16); |
| } |
| |
| .parade-cabinet header { |
| padding: 22px; |
| background: var(--cabinet); |
| border-bottom: 2px solid var(--ink); |
| } |
| |
| .parade-cabinet header span, |
| .poster-card span { |
| display: block; |
| font-size: 0.72rem; |
| font-weight: 900; |
| letter-spacing: 0.14em; |
| text-transform: uppercase; |
| } |
| |
| .parade-cabinet h2 { |
| margin: 5px 0; |
| color: var(--cabinet-ink) !important; |
| font-size: clamp(1.6rem, 3vw, 3rem); |
| letter-spacing: 0; |
| } |
| |
| .route-stage { |
| position: relative; |
| min-height: 440px; |
| background: |
| radial-gradient(circle at 82% 18%, rgba(241, 184, 75, 0.38), transparent 18%), |
| linear-gradient(180deg, rgba(255,255,255,0.08), transparent); |
| } |
| |
| .route-stage svg { |
| width: 100%; |
| height: 440px; |
| display: block; |
| } |
| |
| .route-stage rect { |
| fill: rgba(255, 248, 223, 0.15); |
| stroke: rgba(255, 248, 223, 0.45); |
| stroke-width: 3; |
| } |
| |
| .route-stage polyline { |
| fill: none; |
| stroke: var(--gold); |
| stroke-width: 13; |
| stroke-linecap: round; |
| stroke-linejoin: round; |
| stroke-dasharray: 36 16; |
| animation: route-march 2.8s linear infinite; |
| } |
| |
| .route-stage text { |
| fill: var(--cabinet-ink); |
| font-weight: 900; |
| letter-spacing: 0; |
| } |
| |
| .route-steps i { |
| position: absolute; |
| width: 18px; |
| height: 18px; |
| border-radius: 50%; |
| background: var(--clay); |
| border: 2px solid var(--cabinet-ink); |
| animation: step-pop 1.4s ease-in-out infinite; |
| } |
| |
| .float { |
| position: absolute; |
| display: grid; |
| place-items: center; |
| width: 58px; |
| height: 48px; |
| border: 3px solid var(--ink); |
| border-radius: 8px; |
| background: var(--paper); |
| color: var(--ink); |
| font-weight: 900; |
| box-shadow: 0 8px 0 rgba(0,0,0,0.16); |
| } |
| |
| .float.one { left: 14%; top: 58%; animation: float-one 6s ease-in-out infinite; } |
| .float.two { left: 43%; top: 30%; animation: float-two 6.6s ease-in-out infinite; } |
| .float.three { left: 71%; top: 58%; animation: float-three 7.2s ease-in-out infinite; } |
| |
| .float-grid { |
| display: grid; |
| grid-template-columns: repeat(3, minmax(0, 1fr)); |
| gap: 10px; |
| padding: 12px; |
| } |
| |
| .float-grid article { |
| min-height: 152px; |
| padding: 13px; |
| border: 2px solid rgba(255, 248, 223, 0.24); |
| border-radius: 8px; |
| background: rgba(255, 248, 223, 0.09); |
| color: var(--cabinet-ink); |
| } |
| |
| .float-grid span { |
| color: var(--gold); |
| font-size: 0.7rem; |
| font-weight: 900; |
| text-transform: uppercase; |
| } |
| |
| .float-grid strong, |
| .float-grid p { |
| display: block; |
| color: var(--cabinet-ink) !important; |
| overflow-wrap: anywhere; |
| } |
| |
| .float-grid strong { |
| margin: 5px 0; |
| font-size: 1rem; |
| } |
| |
| .parade-cabinet footer { |
| padding: 18px 22px; |
| border-top: 2px solid rgba(255, 248, 223, 0.24); |
| background: rgba(0,0,0,0.18); |
| } |
| |
| .parade-cabinet footer strong, |
| .parade-cabinet footer p { |
| display: block; |
| margin: 0; |
| color: var(--cabinet-ink) !important; |
| } |
| |
| .parade-cabinet footer p { |
| margin-top: 6px; |
| font-size: 1.25rem; |
| font-weight: 900; |
| } |
| |
| .poster-card { |
| padding: 26px; |
| border: 2px solid var(--ink); |
| border-radius: 8px; |
| background: |
| linear-gradient(135deg, rgba(15, 91, 82, 0.12), transparent 40%), |
| var(--paper); |
| color: var(--ink); |
| } |
| |
| .poster-card h2 { |
| margin: 5px 0 8px; |
| color: var(--ink) !important; |
| font-size: clamp(1.5rem, 3vw, 2.6rem); |
| } |
| |
| .poster-card p, |
| .poster-card strong, |
| .poster-card em { |
| display: block; |
| color: #31524c !important; |
| font-weight: 900; |
| } |
| |
| .poster-card strong { |
| margin: 14px 0; |
| color: var(--clay) !important; |
| font-size: 1.2rem; |
| } |
| |
| .log-box textarea, |
| .caption-box textarea { |
| font-family: ui-monospace, Consolas, monospace !important; |
| } |
| |
| @keyframes route-march { |
| to { stroke-dashoffset: -52; } |
| } |
| |
| @keyframes step-pop { |
| 0%, 100% { transform: scale(0.85); } |
| 50% { transform: scale(1.22); } |
| } |
| |
| @keyframes float-one { |
| 50% { transform: translate(210px, -140px) rotate(-3deg); } |
| } |
| |
| @keyframes float-two { |
| 50% { transform: translate(180px, 100px) rotate(3deg); } |
| } |
| |
| @keyframes float-three { |
| 50% { transform: translate(80px, -80px) rotate(-2deg); } |
| } |
| |
| @media (max-width: 820px) { |
| .float-grid { |
| grid-template-columns: 1fr; |
| } |
| .control-card { |
| position: static; |
| } |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| .route-stage polyline, |
| .route-steps i, |
| .float { |
| animation: none; |
| } |
| } |
| """ |
|
|
|
|
| def initial_state(): |
| req = ParadeRequest( |
| town=TOWNS[1], |
| weather=WEATHERS[0], |
| marshal=MARSHALS[0], |
| trouble=TROUBLES[0], |
| color="mint night", |
| energy=4, |
| provider="Practice writer", |
| cloud_mode=False, |
| ) |
| plan = fallback_parade(req) |
| trace = {"mode": "fallback", "provider": req.provider, "model": None, "error": "ready state"} |
| return ( |
| parade_html(req, plan, trace), |
| poster_html(req, plan, trace), |
| render_parade_audio(req, plan), |
| caption_for(req, plan), |
| log_entry(req, plan, trace), |
| ) |
|
|
|
|
| initial_parade, initial_poster, initial_audio, initial_caption, initial_log = initial_state() |
|
|
|
|
| with gr.Blocks(css=CSS, theme=gr.themes.Base(), title=APP_TITLE) as demo: |
| with gr.Column(elem_id="cloud-parade-shell"): |
| gr.Markdown( |
| """ |
| # Cloud Parade Cabinet |
| Build a tiny impossible parade. Pick the ingredients, open the cabinet, and watch the route come alive. |
| """, |
| elem_id="cloud-parade-title", |
| ) |
| with gr.Row(equal_height=False): |
| with gr.Column(scale=1, elem_classes="control-card"): |
| cloud_mode = gr.Checkbox(value=True, label="Let the cabinet write live") |
| town = gr.Dropdown(TOWNS, value=TOWNS[1], label="Town") |
| weather = gr.Dropdown(WEATHERS, value=WEATHERS[0], label="Parade weather") |
| marshal = gr.Dropdown(MARSHALS, value=MARSHALS[0], label="Grand marshal") |
| trouble = gr.Dropdown(TROUBLES, value=TROUBLES[0], label="Street trouble") |
| color = gr.Radio(list(COLORS), value="mint night", label="Cabinet color") |
| energy = gr.Slider(1, 5, value=4, step=1, label="Parade energy") |
| provider = gr.Radio(PROVIDER_CHOICES, value=PROVIDERS[0], label="Parade voice") |
| with gr.Row(): |
| randomize = gr.Button("Fresh setup") |
| run = gr.Button("Open cabinet", variant="primary") |
| with gr.Column(scale=2): |
| parade = gr.HTML(value=initial_parade) |
| with gr.Row(): |
| poster = gr.HTML(value=initial_poster) |
| with gr.Column(): |
| audio = gr.Audio(value=initial_audio, label="Parade sound", type="filepath", elem_classes="sound-box") |
| caption = gr.Textbox(value=initial_caption, label="Post caption", lines=5, show_copy_button=True, elem_classes="caption-box") |
| log = gr.Textbox(value=initial_log, label="Keepsake log", lines=10, show_copy_button=True, elem_classes="log-box") |
| trace = gr.JSON(label="Run details", visible=False) |
| history = gr.State([]) |
|
|
| randomize.click(random_setup, outputs=[town, weather, marshal, trouble, color, energy], queue=False) |
| run.click( |
| build_parade, |
| inputs=[town, weather, marshal, trouble, color, energy, provider, cloud_mode, history], |
| outputs=[parade, poster, audio, caption, log, history, trace], |
| ) |
| demo.queue(max_size=8, default_concurrency_limit=1) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|