angkit007 commited on
Commit
af26909
Β·
1 Parent(s): 36739be
Files changed (2) hide show
  1. app.py +476 -117
  2. requirements.txt +1 -1
app.py CHANGED
@@ -1,14 +1,17 @@
1
  """
2
- app_single.py β€” MiniCPM-V 4.6 Β· Emberglade
3
- ==================================================
4
- A vision-language playground: MiniCPM-V describes an uploaded image,
5
- then choreographs a cat to perform that mood on a spotlight stage.
 
6
 
7
  Pipeline:
8
  1. Upload image β†’ MiniCPM-V streams a description
9
  2. Model returns a JSON dance spec (mood + 6 numeric animation params)
10
- 3. The cat performs on stage using those exact params β€” every move
11
- is model-determined, not hardcoded.
 
 
12
 
13
  Dance params returned by model:
14
  mood : one of 10 mood words
@@ -19,6 +22,12 @@ Dance params returned by model:
19
  tail_range : tail swing degrees (5 … 120)
20
  ear_tilt : ear rotation degrees (0 … 25)
21
 
 
 
 
 
 
 
22
  Run locally:
23
  pip install -r requirements.txt
24
  python app_single.py
@@ -59,18 +68,20 @@ PROMPT_EXAMPLES = [
59
  ["Explain this image to someone who cannot see it."],
60
  ]
61
 
62
- # ── Mood palettes β€” each mood is a "spotlight color" on the dark stage ────────
 
 
63
  MOOD_PALETTE = {
64
- "happy": {"bg":"#1a1605","body":"#FFD166","detail":"#E8A23A","eye":"#2D1B00","nose":"#FF8A3D","pcol":"#FFE08A","particle":"✦","label":"Happy","caption":"Bouncing with joy"},
65
- "sad": {"bg":"#0c1116","body":"#8AA0B2","detail":"#5D7A8E","eye":"#1A2530","nose":"#B7C7D2","pcol":"#A9C8E0","particle":"Β·","label":"Sad","caption":"Slow, heavy steps"},
66
- "calm": {"bg":"#0a1614","body":"#6FBFB3","detail":"#4A9C8F","eye":"#0A2018","nose":"#A8E0D6","pcol":"#BFEDE4","particle":"β—‹","label":"Calm","caption":"Drifting at ease"},
67
- "energetic": {"bg":"#1a0e05","body":"#FF8A5B","detail":"#E8623A","eye":"#1a0500","nose":"#FFD1BC","pcol":"#FFCB6B","particle":"β˜…","label":"Energetic","caption":"Can't sit still"},
68
- "mysterious": {"bg":"#120c1a","body":"#A98BD6","detail":"#6D4FA8","eye":"#F0B8FF","nose":"#D9C2EE","pcol":"#C7B3F0","particle":"✧","label":"Mysterious","caption":"Slipping through shadow"},
69
- "romantic": {"bg":"#1a0c12","body":"#F2A0BD","detail":"#D9648D","eye":"#1a0010","nose":"#FBE0EA","pcol":"#F7B8CE","particle":"β™₯","label":"Romantic","caption":"A slow, dreamy waltz"},
70
- "tense": {"bg":"#100808","body":"#F0726E","detail":"#C03C38","eye":"#FFB3AE","nose":"#F7C7C4","pcol":"#F2A6A2","particle":"|","label":"Tense","caption":"Coiled and alert"},
71
- "nostalgic": {"bg":"#160f06","body":"#F2C083","detail":"#D98A3D","eye":"#160f06","nose":"#FBE3C7","pcol":"#F7DDB5","particle":"β—¦","label":"Nostalgic","caption":"Rocking to old memories"},
72
- "angry": {"bg":"#160505","body":"#F0635E","detail":"#A8201C","eye":"#FF6961","nose":"#F7B0AC","pcol":"#F58F8A","particle":"✸","label":"Angry","caption":"Stomping, full of fire"},
73
- "neutral": {"bg":"#0e0f13","body":"#A6ADB8","detail":"#727A86","eye":"#0d0d18","nose":"#D8DDE3","pcol":"#C7CDD6","particle":"Β·","label":"Neutral","caption":"Steady and unhurried"},
74
  }
75
 
76
  # ── Default dance specs (fallback if model call fails) ────────────────────────
@@ -147,6 +158,24 @@ Choose values that physically match the scene mood. An energetic scene should ha
147
  low speed (fast), high jump, high sway. A calm scene should have high speed (slow),
148
  low jump, low sway. Be creative β€” the cat's whole body expresses the image's emotion."""
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def get_dance_spec(description: str, api_key: str) -> tuple[str, dict]:
151
  """
152
  Returns (mood, dance_params_dict).
@@ -184,25 +213,166 @@ def get_dance_spec(description: str, api_key: str) -> tuple[str, dict]:
184
  return mood, dance
185
 
186
  except Exception:
187
- # keyword fallback for mood, default params
188
- t = description.lower()
189
- mood = "neutral"
190
- for m, kws in [
191
- ("happy",["happy","joy","celebrate","laugh","smile","bright","sunny"]),
192
- ("sad",["sad","lonely","rain","sorrow","grief","cry","gloom"]),
193
- ("energetic",["energetic","vibrant","excited","dynamic","rush","active"]),
194
- ("calm",["calm","peaceful","quiet","gentle","serene","still"]),
195
- ("mysterious",["mysterious","dark","eerie","shadow","mystic","fog"]),
196
- ("romantic",["romantic","love","tender","intimate","warm","soft"]),
197
- ("tense",["tense","anxious","fear","alarm","nervous","danger"]),
198
- ("nostalgic",["nostalgic","memory","vintage","old","past","retro"]),
199
- ("angry",["angry","furious","rage","fierce","storm"]),
200
- ]:
201
- if any(w in t for w in kws):
202
- mood = m
203
- break
204
  return mood, DEFAULT_DANCE[mood]
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  # ── Keyword dance for text-only tab (no API needed) ───────────────────────────
207
  def generate_animation(text: str) -> str:
208
  t = text.lower()
@@ -224,72 +394,102 @@ def generate_animation(text: str) -> str:
224
  return cat_html(mood, DEFAULT_DANCE[mood])
225
 
226
  # ── Stage chrome β€” shared studio frame ────────────────────────────────────────
227
- STAGE_FONT = "'Space Grotesk', 'Inter', system-ui, sans-serif"
228
- MONO_FONT = "'JetBrains Mono', 'SFMono-Regular', Consolas, monospace"
 
229
 
230
  def _stage_open(spotlight_color: str, breathe_speed: float = 4.0) -> str:
231
- """Opening <div> + shared <style> for the stage frame with a breathing spotlight."""
232
  return f"""<div class="stage" style="--spot:{spotlight_color};">
233
  <style>
234
- @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;500&display=swap');
235
 
236
  .stage {{
237
- position:relative; height:440px; border-radius:16px;
238
  overflow:hidden; isolation:isolate;
239
  background:
240
- radial-gradient(ellipse 70% 55% at 50% 28%, color-mix(in srgb, var(--spot) 22%, transparent), transparent 70%),
241
- linear-gradient(180deg, #11141b 0%, #0B0E14 100%);
242
- border:1px solid #1E2430;
243
  display:flex; flex-direction:column; align-items:center; justify-content:center;
244
  font-family:{STAGE_FONT};
245
  }}
246
  @keyframes spot_breathe {{
247
- 0%,100% {{ opacity:.85; }}
248
  50% {{ opacity:1; }}
249
  }}
250
  .stage::before {{
251
  content:''; position:absolute; inset:0; pointer-events:none;
252
- background: radial-gradient(ellipse 45% 38% at 50% 22%, color-mix(in srgb, var(--spot) 30%, transparent), transparent 72%);
253
  animation: spot_breathe {breathe_speed}s ease-in-out infinite;
254
- mix-blend-mode: screen;
255
  }}
 
256
  .stage::after {{
257
- content:''; position:absolute; inset:0; pointer-events:none;
258
- background-image:
259
- repeating-linear-gradient(0deg, rgba(255,255,255,.012) 0px, rgba(255,255,255,.012) 1px, transparent 1px, transparent 3px),
260
- repeating-linear-gradient(90deg, rgba(255,255,255,.012) 0px, rgba(255,255,255,.012) 1px, transparent 1px, transparent 3px);
261
  }}
262
 
263
  .stage-cue {{
264
- position:absolute; top:18px; left:0; right:0;
265
- display:flex; align-items:center; justify-content:center; gap:10px;
266
- font-size:.72rem; letter-spacing:.22em; text-transform:uppercase;
267
- color:#9CA3AF; font-weight:500; z-index:3;
 
268
  }}
269
  .stage-cue .dot {{
270
- width:7px; height:7px; border-radius:50%;
271
- background:var(--spot); box-shadow:0 0 8px var(--spot);
272
  }}
273
  .stage-cue .mood-name {{
274
- color:#F5F1E8; font-weight:700; letter-spacing:.14em;
 
 
 
275
  }}
276
 
277
  .stage-caption {{
278
- position:absolute; bottom:46px; left:0; right:0; text-align:center; z-index:3;
279
- color:#9CA3AF; font-size:.82rem; letter-spacing:.03em; font-style:italic;
 
280
  }}
281
 
282
  .cue-sheet {{
283
  position:absolute; bottom:14px; left:0; right:0; z-index:3;
284
- display:flex; justify-content:center; gap:14px; flex-wrap:wrap;
285
  padding:0 20px;
286
  }}
287
  .cue-chip {{
288
- font-family:{MONO_FONT}; font-size:.66rem; letter-spacing:.04em;
289
- color:#9CA3AF; background:#13171F; border:1px solid #1E2430;
290
- border-radius:6px; padding:3px 9px; white-space:nowrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  }}
292
- .cue-chip b {{ color:var(--spot); font-weight:500; }}
 
 
 
 
 
 
 
 
293
  </style>
294
  """
295
 
@@ -306,6 +506,16 @@ def cat_html(mood: str, dance: dict) -> str:
306
 
307
  t0 = -tr // 2; t1 = tr // 2
308
  breathe = max(2.0, min(6.0, sp * 2))
 
 
 
 
 
 
 
 
 
 
309
 
310
  cue_chips = (
311
  f'<span class="cue-chip">speed <b>{sp}s</b></span>'
@@ -470,8 +680,7 @@ def cat_html(mood: str, dance: dict) -> str:
470
 
471
  .c-particle {{
472
  position:absolute; pointer-events:none;
473
- color:{p['pcol']}; font-size:.9rem;
474
- text-shadow:0 0 4px {p['pcol']};
475
  opacity:0;
476
  animation:K_part var(--pd) var(--pde) ease-out infinite;
477
  }}
@@ -480,9 +689,13 @@ def cat_html(mood: str, dance: dict) -> str:
480
  <div class="stage-cue">
481
  <span class="dot"></span>
482
  <span class="mood-name">{p['label']}</span>
483
- <span>&nbsp;Β·&nbsp;now performing</span>
484
  </div>
485
 
 
 
 
 
486
  <div class="cat-wrap" id="cw">
487
  <div class="cat-shadow"></div>
488
  <div class="cat-unit">
@@ -527,39 +740,124 @@ def cat_html(mood: str, dance: dict) -> str:
527
  el.style.fontSize = (.55+Math.random()*.65).toFixed(2)+'rem';
528
  wrap.appendChild(el);
529
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  }})();
531
  </script>""" + _stage_close()
532
 
533
  def placeholder_html():
534
- return _stage_open("#9CA3AF", 6.0) + f"""
535
- <div style="text-align:center; z-index:2; color:#9CA3AF; font-family:{STAGE_FONT};">
536
- <div style="font-size:2.4rem; margin-bottom:14px; opacity:.35;">🐾</div>
537
- <div style="font-size:.95rem; font-weight:700; letter-spacing:.05em; color:#F5F1E8; margin-bottom:6px;">
538
- Waiting for a performance
539
  </div>
540
- <div style="font-size:.78rem; color:#6B7280; max-width:260px; margin:0 auto; line-height:1.6;">
541
- Upload an image β€” the model reads its mood and choreographs every move the cat makes.
 
542
  </div>
543
  </div>""" + _stage_close()
544
 
545
- def loading_html() -> str:
546
- return _stage_open("#FF8A3D", 2.0) + f"""
547
- <div style="text-align:center; z-index:2; color:#9CA3AF; font-family:{STAGE_FONT};">
 
 
 
548
  <div class="loading-spinner" style="
549
- width:34px; height:34px; margin:0 auto 16px;
550
- border:3px solid #1E2430; border-top-color:#FF8A3D;
551
  border-radius:50%; animation: spin 0.9s linear infinite;"></div>
552
- <div style="font-size:.85rem; letter-spacing:.06em; color:#F5F1E8; font-weight:700;">
553
- Reading the room…
554
  </div>
555
- <div style="font-size:.74rem; color:#6B7280; margin-top:4px;">
556
- choreographing the next performance
557
  </div>
558
  </div>
559
  <style>@keyframes spin {{ to {{ transform: rotate(360deg); }} }}</style>""" + _stage_close()
560
 
561
  # ── Main pipeline ─────────────────────────────────────────────────────────────
562
- def run_image_pipeline(image, prompt, model_label, max_tokens, temperature, api_key):
 
 
 
 
 
 
 
 
 
 
563
  final_desc = ""
564
  for partial in stream_description(image, prompt, model_label, max_tokens, temperature, api_key):
565
  final_desc = partial
@@ -570,20 +868,21 @@ def run_image_pipeline(image, prompt, model_label, max_tokens, temperature, api_
570
  yield final_desc, cat_html(mood, dance)
571
 
572
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
573
- # UI β€” Emberglade
574
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
575
 
576
  CSS = """
577
- @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
578
 
579
  :root {
580
- --bg: #0B0E14;
581
- --surface: #13171F;
582
- --raised: #1E2430;
583
- --text: #F5F1E8;
584
- --text-dim: #9CA3AF;
585
  --text-faint:#6B7280;
586
- --accent: #FF8A3D;
 
587
  }
588
 
589
  .gradio-container {
@@ -593,13 +892,16 @@ CSS = """
593
 
594
  /* ── Header ────────────────────────────────────────────────────────────── */
595
  #studio-header {
596
- text-align:center; padding: 8px 0 4px;
 
 
 
597
  }
598
  #studio-header h1 {
599
  font-family:'Space Grotesk', sans-serif !important;
600
  font-weight:700 !important; letter-spacing:.01em;
601
  font-size:1.9rem !important; color:var(--text) !important;
602
- margin-bottom:4px !important;
603
  }
604
  #studio-header p {
605
  color:var(--text-dim) !important; font-size:.92rem !important;
@@ -608,41 +910,48 @@ CSS = """
608
  #studio-header .eyebrow {
609
  display:inline-flex; align-items:center; gap:8px;
610
  font-family:'JetBrains Mono', monospace; font-size:.7rem;
611
- letter-spacing:.22em; text-transform:uppercase;
612
- color:var(--accent); margin-bottom:10px;
 
 
 
 
 
 
613
  }
614
- #studio-header .eyebrow .line {
615
- width:28px; height:1px; background:var(--accent); opacity:.5;
 
616
  }
617
 
618
  /* ── Panels ────────────────────────────────────────────────────────────── */
619
  .gr-form, .gr-box, .gr-panel, .gr-block.gr-box {
620
- background: var(--surface) !important;
621
  border: 1px solid var(--raised) !important;
622
- border-radius: 12px !important;
623
  }
624
 
625
  /* Section labels */
626
  .gradio-container label span {
627
  font-family:'Inter', sans-serif !important;
628
  font-size:.78rem !important; font-weight:600 !important;
629
- letter-spacing:.04em !important; color:var(--text-dim) !important;
630
  }
631
 
632
  /* ── Buttons ───────────────────────────────────────────────────────────── */
633
  #submit-img, #submit-txt {
634
  background: var(--accent) !important;
635
- color: #1A0E05 !important;
636
- border: none !important;
637
  font-weight:700 !important;
638
- letter-spacing:.04em !important;
639
  font-family:'Space Grotesk', sans-serif !important;
640
- box-shadow: 0 0 0 1px rgba(255,138,61,.0), 0 6px 18px -8px var(--accent) !important;
641
  transition: transform .12s ease, box-shadow .12s ease !important;
642
  }
643
  #submit-img:hover, #submit-txt:hover {
644
  transform: translateY(-1px);
645
- box-shadow: 0 10px 24px -8px var(--accent) !important;
646
  }
647
  #submit-img:active, #submit-txt:active { transform: translateY(0); }
648
 
@@ -656,8 +965,8 @@ CSS = """
656
 
657
  /* ── Run-locally panel ─────────────────────────────────────────────────── */
658
  #run-locally {
659
- border:1px dashed var(--raised) !important;
660
- background: transparent !important;
661
  }
662
  #run-locally code {
663
  font-family:'JetBrains Mono', monospace !important;
@@ -665,7 +974,7 @@ CSS = """
665
  background:var(--bg) !important;
666
  border:1px solid var(--raised) !important;
667
  border-radius:6px !important;
668
- color:var(--accent) !important;
669
  }
670
  #run-locally pre {
671
  background:var(--bg) !important;
@@ -677,11 +986,12 @@ CSS = """
677
  /* ── Tabs ──────────────────────────────────────────────────────────────── */
678
  .tab-nav button {
679
  font-family:'Space Grotesk', sans-serif !important;
680
- font-weight:600 !important; letter-spacing:.02em !important;
681
  color: var(--text-dim) !important;
682
  }
683
  .tab-nav button.selected {
684
- color: var(--accent) !important;
 
685
  }
686
 
687
  /* ── Misc ──────────────────────────────────────────────────────────────── */
@@ -713,15 +1023,39 @@ $env:MINICPM_API_KEY="sk-your-key-here"
713
 
714
  The app checks `MINICPM_API_KEY` first, then the **API Key** field below,
715
  then falls back to the shared public key.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  """
717
 
718
- with gr.Blocks(title="Emberglade Β· MiniCPM-V 4.6", theme=gr.themes.Soft(), css=CSS) as demo:
719
 
720
  gr.HTML(
721
  """<div id="studio-header">
722
- <div class="eyebrow"><span class="line"></span>MINICPM-V 4.6 Β· LIVE CHOREOGRAPHY<span class="line"></span></div>
723
- <h1>Emberglade</h1>
724
- <p>Upload an image. The model reads its mood β€” then choreographs every move, live.</p>
 
 
 
725
  </div>"""
726
  )
727
 
@@ -732,15 +1066,37 @@ with gr.Blocks(title="Emberglade Β· MiniCPM-V 4.6", theme=gr.themes.Soft(), css=
732
  with gr.Column(scale=1):
733
  image_input = gr.Image(type="pil", label="Upload image", height=240)
734
  prompt_input = gr.Textbox(value=DEFAULT_PROMPT, label="Prompt", lines=2)
 
 
 
 
 
 
 
735
  model_sel = gr.Radio(choices=list(MODELS.keys()),
736
- value=list(MODELS.keys())[0], label="Model")
 
 
737
  with gr.Accordion("Generation settings", open=False):
738
  max_tok = gr.Slider(64, 2048, value=DEFAULT_MAX_TOKENS, step=64, label="Max tokens")
739
  temp = gr.Slider(0.0, 1.5, value=DEFAULT_TEMPERATURE, step=0.05, label="Temperature")
 
740
  with gr.Accordion("API key", open=False):
741
  api_key = gr.Textbox(label="Your key (optional)", type="password",
742
  placeholder="sk-… leave blank to use the shared key")
743
  gr.Markdown("Get your own at [modelbest.cn](https://modelbest.cn) β€” see **Run locally** below for setup.")
 
 
 
 
 
 
 
 
 
 
 
 
744
  img_btn = gr.Button("Start performance", variant="primary", elem_id="submit-img")
745
  gr.Examples(examples=PROMPT_EXAMPLES, inputs=[prompt_input], label="Prompt ideas")
746
 
@@ -750,16 +1106,19 @@ with gr.Blocks(title="Emberglade Β· MiniCPM-V 4.6", theme=gr.themes.Soft(), css=
750
  placeholder="The model's description will stream in here…",
751
  elem_id="desc-output")
752
 
 
 
753
  img_btn.click(
754
  fn=run_image_pipeline,
755
- inputs=[image_input, prompt_input, model_sel, max_tok, temp, api_key],
756
  outputs=[desc_out, cat_out],
757
  )
758
  prompt_input.submit(
759
  fn=run_image_pipeline,
760
- inputs=[image_input, prompt_input, model_sel, max_tok, temp, api_key],
761
  outputs=[desc_out, cat_out],
762
  )
 
763
 
764
  # ── Tab 2: Text-only (keyword dance, no API) ──────────────────────────
765
  with gr.TabItem("✍️ Text β†’ Performance"):
 
1
  """
2
+ app_single.py β€” MiniCPM-V 4.6 Β· An Adventure in Thousand Token Wood
3
+ =====================================================================
4
+ A storybook playground: MiniCPM-V reads an uploaded image like a page
5
+ from an adventure, then a woodland cat performs its mood in a forest
6
+ clearing β€” complete with a tiny generative tune.
7
 
8
  Pipeline:
9
  1. Upload image β†’ MiniCPM-V streams a description
10
  2. Model returns a JSON dance spec (mood + 6 numeric animation params)
11
+ 3. The cat performs in the clearing using those exact params β€” every
12
+ move is model-determined, not hardcoded.
13
+ 4. A free, generative melody (Web Audio API, no audio files) plays
14
+ along β€” tempo and register also derived from the model's params.
15
 
16
  Dance params returned by model:
17
  mood : one of 10 mood words
 
22
  tail_range : tail swing degrees (5 … 120)
23
  ear_tilt : ear rotation degrees (0 … 25)
24
 
25
+ Two backends β€” switchable in the UI:
26
+ β€’ API (default) β€” calls the hosted MiniCPM-V 4.6 API. Needs internet.
27
+ β€’ Local (offline) β€” downloads openbmb/MiniCPM-V-4 (4.1B, Apache-2.0) once,
28
+ caches it to ./model_cache/, then runs fully offline.
29
+ Requires: pip install torch transformers accelerate
30
+
31
  Run locally:
32
  pip install -r requirements.txt
33
  python app_single.py
 
68
  ["Explain this image to someone who cannot see it."],
69
  ]
70
 
71
+ # ── Mood palettes β€” each mood is a "firefly color" in the wood ────────────────
72
+ # scale: semitone offsets from root (a small mode/scale per mood)
73
+ # root : MIDI-ish base note number (we map to Hz with 440 * 2^((n-69)/12))
74
  MOOD_PALETTE = {
75
+ "happy": {"bg":"#1a1605","body":"#FFD166","detail":"#E8A23A","eye":"#2D1B00","nose":"#FF8A3D","pcol":"#FFE08A","particle":"✦","label":"Happy","caption":"Bouncing with joy", "scale":[0,2,4,7,9,12], "root":72},
76
+ "sad": {"bg":"#0c1116","body":"#8AA0B2","detail":"#5D7A8E","eye":"#1A2530","nose":"#B7C7D2","pcol":"#A9C8E0","particle":"Β·","label":"Sad","caption":"Slow, heavy steps", "scale":[0,3,5,7,10,12], "root":60},
77
+ "calm": {"bg":"#0a1614","body":"#6FBFB3","detail":"#4A9C8F","eye":"#0A2018","nose":"#A8E0D6","pcol":"#BFEDE4","particle":"β—‹","label":"Calm","caption":"Drifting at ease", "scale":[0,2,5,7,9,12], "root":64},
78
+ "energetic": {"bg":"#1a0e05","body":"#FF8A5B","detail":"#E8623A","eye":"#1a0500","nose":"#FFD1BC","pcol":"#FFCB6B","particle":"β˜…","label":"Energetic","caption":"Can't sit still", "scale":[0,2,4,5,7,9,11,12],"root":71},
79
+ "mysterious": {"bg":"#120c1a","body":"#A98BD6","detail":"#6D4FA8","eye":"#F0B8FF","nose":"#D9C2EE","pcol":"#C7B3F0","particle":"✧","label":"Mysterious","caption":"Slipping through shadow", "scale":[0,1,4,5,7,8,11,12],"root":62},
80
+ "romantic": {"bg":"#1a0c12","body":"#F2A0BD","detail":"#D9648D","eye":"#1a0010","nose":"#FBE0EA","pcol":"#F7B8CE","particle":"β™₯","label":"Romantic","caption":"A slow, dreamy waltz", "scale":[0,2,4,7,9,12], "root":67},
81
+ "tense": {"bg":"#100808","body":"#F0726E","detail":"#C03C38","eye":"#FFB3AE","nose":"#F7C7C4","pcol":"#F2A6A2","particle":"|","label":"Tense","caption":"Coiled and alert", "scale":[0,1,3,6,7,10,12], "root":61},
82
+ "nostalgic": {"bg":"#160f06","body":"#F2C083","detail":"#D98A3D","eye":"#160f06","nose":"#FBE3C7","pcol":"#F7DDB5","particle":"β—¦","label":"Nostalgic","caption":"Rocking to old memories", "scale":[0,2,3,7,9,12], "root":65},
83
+ "angry": {"bg":"#160505","body":"#F0635E","detail":"#A8201C","eye":"#FF6961","nose":"#F7B0AC","pcol":"#F58F8A","particle":"✸","label":"Angry","caption":"Stomping, full of fire", "scale":[0,1,3,5,6,8,10,12],"root":59},
84
+ "neutral": {"bg":"#0e0f13","body":"#A6ADB8","detail":"#727A86","eye":"#0d0d18","nose":"#D8DDE3","pcol":"#C7CDD6","particle":"Β·","label":"Neutral","caption":"Steady and unhurried", "scale":[0,2,4,7,9,12], "root":64},
85
  }
86
 
87
  # ── Default dance specs (fallback if model call fails) ────────────────────────
 
158
  low speed (fast), high jump, high sway. A calm scene should have high speed (slow),
159
  low jump, low sway. Be creative β€” the cat's whole body expresses the image's emotion."""
160
 
161
+ def _keyword_mood(description: str) -> str:
162
+ """Simple keyword-based mood fallback when JSON parsing fails."""
163
+ t = description.lower()
164
+ for m, kws in [
165
+ ("happy",["happy","joy","celebrate","laugh","smile","bright","sunny"]),
166
+ ("sad",["sad","lonely","rain","sorrow","grief","cry","gloom"]),
167
+ ("energetic",["energetic","vibrant","excited","dynamic","rush","active"]),
168
+ ("calm",["calm","peaceful","quiet","gentle","serene","still"]),
169
+ ("mysterious",["mysterious","dark","eerie","shadow","mystic","fog"]),
170
+ ("romantic",["romantic","love","tender","intimate","warm","soft"]),
171
+ ("tense",["tense","anxious","fear","alarm","nervous","danger"]),
172
+ ("nostalgic",["nostalgic","memory","vintage","old","past","retro"]),
173
+ ("angry",["angry","furious","rage","fierce","storm"]),
174
+ ]:
175
+ if any(w in t for w in kws):
176
+ return m
177
+ return "neutral"
178
+
179
  def get_dance_spec(description: str, api_key: str) -> tuple[str, dict]:
180
  """
181
  Returns (mood, dance_params_dict).
 
213
  return mood, dance
214
 
215
  except Exception:
216
+ mood = _keyword_mood(description)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  return mood, DEFAULT_DANCE[mood]
218
 
219
+
220
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
221
+ # OFFLINE / LOCAL BACKEND
222
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
223
+ # Runs entirely on this machine, no internet required after first download.
224
+ # Model : openbmb/MiniCPM-V-4 (4.1B params, Apache-2.0, fully public)
225
+ # Cache : ./model_cache/ (weights) + .download_complete (sentinel)
226
+ #
227
+ # Heavy deps (torch, transformers) are imported lazily β€” only when the
228
+ # user actually selects the Local backend β€” so API-only users don't need
229
+ # them installed.
230
+
231
+ from pathlib import Path
232
+
233
+ LOCAL_MODEL_ID = "openbmb/MiniCPM-V-4"
234
+ LOCAL_CACHE_DIR = Path(__file__).parent / "model_cache"
235
+ LOCAL_SENTINEL = LOCAL_CACHE_DIR / ".download_complete"
236
+
237
+ _local_model = None
238
+ _local_tokenizer = None
239
+
240
+ def local_is_cached() -> bool:
241
+ return LOCAL_SENTINEL.exists()
242
+
243
+ def local_cache_size_gb() -> float:
244
+ if not LOCAL_CACHE_DIR.exists():
245
+ return 0.0
246
+ return sum(f.stat().st_size for f in LOCAL_CACHE_DIR.rglob("*") if f.is_file()) / 1e9
247
+
248
+ def local_status_md() -> str:
249
+ if local_is_cached():
250
+ return (f"βœ… **Model cached** β€” `{LOCAL_MODEL_ID}` "
251
+ f"({local_cache_size_gb():.1f} GB) ready to run offline.")
252
+ return (f"⬇️ **Not downloaded yet** β€” `{LOCAL_MODEL_ID}` (~8 GB) will be "
253
+ f"fetched on first use and cached in `model_cache/`. "
254
+ f"Requires internet for this one-time download.")
255
+
256
+ def _load_local_model():
257
+ """
258
+ Lazily import torch/transformers and load MiniCPM-V-4 from local cache,
259
+ downloading once if needed. Returns (model, tokenizer).
260
+ """
261
+ global _local_model, _local_tokenizer
262
+ if _local_model is not None:
263
+ return _local_model, _local_tokenizer
264
+
265
+ try:
266
+ import torch
267
+ import transformers
268
+ from transformers import AutoModel, AutoTokenizer
269
+ except ImportError as e:
270
+ raise RuntimeError(
271
+ "Local backend requires extra packages.\n"
272
+ "Install with:\n"
273
+ " pip install torch transformers accelerate\n"
274
+ f"(original error: {e})"
275
+ )
276
+
277
+ # transformers v5 broke MiniCPM-V-4's custom code (all_tied_weights_keys)
278
+ _tv = tuple(int(x) for x in transformers.__version__.split(".")[:2])
279
+ if _tv >= (5, 0):
280
+ from transformers import modeling_utils as _mu
281
+ _orig_getattr = getattr(_mu.PreTrainedModel, "__getattr__", None)
282
+ def _safe_getattr(self, name):
283
+ if name == "all_tied_weights_keys":
284
+ return {}
285
+ if _orig_getattr is not None:
286
+ return _orig_getattr(self, name)
287
+ raise AttributeError(name)
288
+ _mu.PreTrainedModel.__getattr__ = _safe_getattr
289
+
290
+ LOCAL_CACHE_DIR.mkdir(parents=True, exist_ok=True)
291
+ local_only = local_is_cached()
292
+
293
+ common = dict(
294
+ trust_remote_code=True,
295
+ cache_dir=str(LOCAL_CACHE_DIR),
296
+ local_files_only=local_only,
297
+ )
298
+
299
+ _local_tokenizer = AutoTokenizer.from_pretrained(LOCAL_MODEL_ID, **common)
300
+
301
+ device = "cuda" if torch.cuda.is_available() else "cpu"
302
+ dtype = torch.float16 if device == "cuda" else torch.float32
303
+
304
+ _local_model = AutoModel.from_pretrained(
305
+ LOCAL_MODEL_ID,
306
+ torch_dtype=dtype,
307
+ attn_implementation="sdpa",
308
+ device_map="auto" if device == "cuda" else None,
309
+ low_cpu_mem_usage=True,
310
+ **common,
311
+ )
312
+ if device == "cpu":
313
+ _local_model = _local_model.to(device)
314
+ _local_model.eval()
315
+
316
+ if not local_only:
317
+ LOCAL_SENTINEL.write_text(f"{LOCAL_MODEL_ID} downloaded.\nDelete to re-download.\n")
318
+
319
+ return _local_model, _local_tokenizer
320
+
321
+ def stream_description_local(image, prompt, max_tokens, temperature):
322
+ """Local (offline) equivalent of stream_description β€” non-streaming, single yield."""
323
+ if image is None:
324
+ yield "⚠️ Please upload an image first."
325
+ return
326
+ try:
327
+ model, tokenizer = _load_local_model()
328
+ msgs = [{"role": "user", "content": [image.convert("RGB"), prompt]}]
329
+ result = model.chat(
330
+ image=image.convert("RGB"),
331
+ msgs=msgs,
332
+ tokenizer=tokenizer,
333
+ sampling=(temperature > 0),
334
+ temperature=max(temperature, 0.01),
335
+ max_new_tokens=max_tokens,
336
+ )
337
+ yield result
338
+ except RuntimeError as e:
339
+ yield f"❌ {e}"
340
+ except Exception as e:
341
+ yield f"❌ Local inference error: {e}"
342
+
343
+ def get_dance_spec_local(description: str) -> tuple[str, dict]:
344
+ """Local equivalent of get_dance_spec β€” one extra text-only local call."""
345
+ if not description or description.startswith(("⚠️","❌")):
346
+ return "neutral", DEFAULT_DANCE["neutral"]
347
+ try:
348
+ model, tokenizer = _load_local_model()
349
+ msgs = [{"role": "user", "content": [
350
+ DANCE_SYSTEM_PROMPT + f"\n\nScene description:\n{description[:800]}"
351
+ ]}]
352
+ raw = model.chat(
353
+ image=None, msgs=msgs, tokenizer=tokenizer,
354
+ sampling=False, max_new_tokens=150,
355
+ )
356
+ raw = re.sub(r"```[a-z]*", "", raw).strip().strip("`").strip()
357
+ spec = json.loads(raw)
358
+
359
+ mood = spec.get("mood","neutral")
360
+ if mood not in MOOD_LABELS:
361
+ mood = "neutral"
362
+
363
+ dance = {
364
+ "speed": float(max(0.3, min(3.0, spec.get("speed", 1.5)))),
365
+ "jump": int(max(0, min(60, spec.get("jump", 10)))),
366
+ "sway": int(max(0, min(20, spec.get("sway", 5)))),
367
+ "tail_speed": float(max(0.2, min(3.0, spec.get("tail_speed", 1.5)))),
368
+ "tail_range": int(max(5, min(200, spec.get("tail_range", 40)))),
369
+ "ear_tilt": int(max(0, min(25, spec.get("ear_tilt", 5)))),
370
+ }
371
+ return mood, dance
372
+ except Exception:
373
+ return _keyword_mood(description), DEFAULT_DANCE[_keyword_mood(description)]
374
+
375
+
376
  # ── Keyword dance for text-only tab (no API needed) ───────────────────────────
377
  def generate_animation(text: str) -> str:
378
  t = text.lower()
 
394
  return cat_html(mood, DEFAULT_DANCE[mood])
395
 
396
  # ── Stage chrome β€” shared studio frame ────────────────────────────────────────
397
+ STAGE_FONT = "'Space Grotesk', 'Inter', system-ui, sans-serif"
398
+ LABEL_FONT = "'Inter', system-ui, sans-serif"
399
+ MONO_FONT = "'JetBrains Mono', 'SFMono-Regular', Consolas, monospace"
400
 
401
  def _stage_open(spotlight_color: str, breathe_speed: float = 4.0) -> str:
402
+ """Opening <div> + shared <style> for the performance card, HF light style."""
403
  return f"""<div class="stage" style="--spot:{spotlight_color};">
404
  <style>
405
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
406
 
407
  .stage {{
408
+ position:relative; height:440px; border-radius:12px;
409
  overflow:hidden; isolation:isolate;
410
  background:
411
+ radial-gradient(ellipse 70% 50% at 50% 22%, color-mix(in srgb, var(--spot) 14%, transparent), transparent 70%),
412
+ #F8F9FA;
413
+ border:1px solid #E5E7EB;
414
  display:flex; flex-direction:column; align-items:center; justify-content:center;
415
  font-family:{STAGE_FONT};
416
  }}
417
  @keyframes spot_breathe {{
418
+ 0%,100% {{ opacity:.7; }}
419
  50% {{ opacity:1; }}
420
  }}
421
  .stage::before {{
422
  content:''; position:absolute; inset:0; pointer-events:none;
423
+ background: radial-gradient(ellipse 45% 36% at 50% 18%, color-mix(in srgb, var(--spot) 18%, transparent), transparent 72%);
424
  animation: spot_breathe {breathe_speed}s ease-in-out infinite;
 
425
  }}
426
+ /* faint dot-grid texture, HF-card style */
427
  .stage::after {{
428
+ content:''; position:absolute; inset:0; pointer-events:none; opacity:.5;
429
+ background-image: radial-gradient(circle, #E5E7EB 1px, transparent 1px);
430
+ background-size: 22px 22px;
 
431
  }}
432
 
433
  .stage-cue {{
434
+ position:absolute; top:16px; left:0; right:0;
435
+ display:flex; align-items:center; justify-content:center; gap:8px;
436
+ font-family:{MONO_FONT};
437
+ font-size:.68rem; letter-spacing:.16em; text-transform:uppercase;
438
+ color:#6B7280; font-weight:500; z-index:3;
439
  }}
440
  .stage-cue .dot {{
441
+ width:8px; height:8px; border-radius:50%;
442
+ background:var(--spot); box-shadow:0 0 0 3px color-mix(in srgb, var(--spot) 25%, transparent);
443
  }}
444
  .stage-cue .mood-name {{
445
+ color:#111827; font-weight:700; letter-spacing:.1em;
446
+ font-family:{MONO_FONT};
447
+ background:#FFFFFF; border:1px solid #E5E7EB;
448
+ border-radius:999px; padding:2px 10px;
449
  }}
450
 
451
  .stage-caption {{
452
+ position:absolute; bottom:62px; left:0; right:0; text-align:center; z-index:3;
453
+ color:#4B5563; font-size:.92rem; letter-spacing:.01em; font-style:italic;
454
+ font-family:{STAGE_FONT}; font-weight:500;
455
  }}
456
 
457
  .cue-sheet {{
458
  position:absolute; bottom:14px; left:0; right:0; z-index:3;
459
+ display:flex; justify-content:center; gap:8px; flex-wrap:wrap;
460
  padding:0 20px;
461
  }}
462
  .cue-chip {{
463
+ font-family:{MONO_FONT}; font-size:.64rem; letter-spacing:.03em;
464
+ color:#374151; background:#FFFFFF; border:1px solid #E5E7EB;
465
+ border-radius:999px; padding:3px 10px; white-space:nowrap;
466
+ box-shadow: 0 1px 2px rgba(0,0,0,.03);
467
+ }}
468
+ .cue-chip b {{ color:#92660C; font-weight:600; }}
469
+
470
+ /* ── music toggle button ── */
471
+ .music-toggle {{
472
+ position:absolute; top:14px; right:14px; z-index:4;
473
+ width:36px; height:36px; border-radius:50%;
474
+ background:#FFFFFF; border:1px solid #E5E7EB;
475
+ display:flex; align-items:center; justify-content:center;
476
+ cursor:pointer; font-size:1rem; color:#374151;
477
+ box-shadow: 0 1px 2px rgba(0,0,0,.04);
478
+ transition: transform .15s ease, background .15s ease, box-shadow .15s ease;
479
+ }}
480
+ .music-toggle:hover {{
481
+ transform: scale(1.06);
482
+ box-shadow: 0 2px 8px rgba(0,0,0,.08);
483
  }}
484
+ .music-toggle.playing {{
485
+ background: #FFD21E;
486
+ border-color: #FFD21E;
487
+ color:#111827;
488
+ }}
489
+ .music-toggle .icon-play {{ display:inline; }}
490
+ .music-toggle .icon-pause {{ display:none; }}
491
+ .music-toggle.playing .icon-play {{ display:none; }}
492
+ .music-toggle.playing .icon-pause {{ display:inline; }}
493
  </style>
494
  """
495
 
 
506
 
507
  t0 = -tr // 2; t1 = tr // 2
508
  breathe = max(2.0, min(6.0, sp * 2))
509
+ stage_id = f"stage_{mood}"
510
+
511
+ # ── music params derived from dance spec ──
512
+ scale = p["scale"]
513
+ root = p["root"]
514
+ # tempo: faster dance (low sp) -> faster notes. Map sp [0.3,3.0] -> note interval [140,520]ms
515
+ note_ms = int(140 + (sp - 0.3) / (3.0 - 0.3) * (520 - 140))
516
+ # register: higher jump -> notes climb higher (octave shift 0,1,2)
517
+ octave_shift = 12 * min(2, jp // 25)
518
+ note_root = root + octave_shift
519
 
520
  cue_chips = (
521
  f'<span class="cue-chip">speed <b>{sp}s</b></span>'
 
680
 
681
  .c-particle {{
682
  position:absolute; pointer-events:none;
683
+ color:{D}; font-size:.9rem;
 
684
  opacity:0;
685
  animation:K_part var(--pd) var(--pde) ease-out infinite;
686
  }}
 
689
  <div class="stage-cue">
690
  <span class="dot"></span>
691
  <span class="mood-name">{p['label']}</span>
692
+ <span>&nbsp;Β·&nbsp;live performance</span>
693
  </div>
694
 
695
+ <button class="music-toggle" id="music_{stage_id}" title="Play the generated tune" aria-label="Toggle music">
696
+ <span class="icon-play">β™ͺ</span><span class="icon-pause">⏸</span>
697
+ </button>
698
+
699
  <div class="cat-wrap" id="cw">
700
  <div class="cat-shadow"></div>
701
  <div class="cat-unit">
 
740
  el.style.fontSize = (.55+Math.random()*.65).toFixed(2)+'rem';
741
  wrap.appendChild(el);
742
  }}
743
+
744
+ // ── Generative tune β€” Web Audio, no files ──
745
+ const scale = {scale};
746
+ const noteRoot= {note_root};
747
+ const noteMs = {note_ms};
748
+ const mood = "{mood}";
749
+
750
+ let ctx = null, timer = null, step = 0, master = null;
751
+
752
+ function midiToFreq(n) {{ return 440 * Math.pow(2, (n - 69) / 12); }}
753
+
754
+ function pattern(stepIdx) {{
755
+ // simple per-mood arpeggio shapes over the scale degrees
756
+ const len = scale.length;
757
+ let degree;
758
+ if (mood === 'energetic' || mood === 'angry') {{
759
+ degree = scale[stepIdx % len]; // straight run, bright
760
+ }} else if (mood === 'sad' || mood === 'nostalgic') {{
761
+ degree = scale[[0,2,1,3][stepIdx % 4] % len]; // gentle up-down
762
+ }} else if (mood === 'mysterious' || mood === 'tense') {{
763
+ degree = scale[[0,3,1,5][stepIdx % 4] % len]; // wider, uneasy leaps
764
+ }} else {{
765
+ degree = scale[[0,1,2,1][stepIdx % 4] % len]; // calm/happy/romantic/calm lilt
766
+ }}
767
+ return noteRoot + degree;
768
+ }}
769
+
770
+ function playNote() {{
771
+ if (!ctx) return;
772
+ const midi = pattern(step);
773
+ const freq = midiToFreq(midi);
774
+ const t0 = ctx.currentTime;
775
+
776
+ const osc = ctx.createOscillator();
777
+ const gain = ctx.createGain();
778
+ osc.type = (mood === 'angry' || mood === 'energetic') ? 'sawtooth'
779
+ : (mood === 'mysterious' || mood === 'tense') ? 'triangle'
780
+ : 'sine';
781
+ osc.frequency.setValueAtTime(freq, t0);
782
+
783
+ const dur = noteMs / 1000 * 0.9;
784
+ gain.gain.setValueAtTime(0.0001, t0);
785
+ gain.gain.exponentialRampToValueAtTime(0.18, t0 + 0.02);
786
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
787
+
788
+ osc.connect(gain).connect(master);
789
+ osc.start(t0);
790
+ osc.stop(t0 + dur + 0.02);
791
+
792
+ step = (step + 1) % 16;
793
+ }}
794
+
795
+ const btn = document.getElementById('music_{stage_id}');
796
+ btn.addEventListener('click', function(){{
797
+ if (!ctx) {{
798
+ ctx = new (window.AudioContext || window.webkitAudioContext)();
799
+ master = ctx.createGain();
800
+ master.gain.value = 0.5;
801
+ master.connect(ctx.destination);
802
+ }}
803
+ if (timer) {{
804
+ clearInterval(timer); timer = null;
805
+ ctx.suspend();
806
+ btn.classList.remove('playing');
807
+ }} else {{
808
+ ctx.resume();
809
+ playNote();
810
+ timer = setInterval(playNote, {note_ms});
811
+ btn.classList.add('playing');
812
+ }}
813
+ }});
814
  }})();
815
  </script>""" + _stage_close()
816
 
817
  def placeholder_html():
818
+ return _stage_open("#FFD21E", 6.0) + f"""
819
+ <div style="text-align:center; z-index:2; color:#6B7280; font-family:{STAGE_FONT};">
820
+ <div style="font-size:2.4rem; margin-bottom:14px; opacity:.6;">🐱</div>
821
+ <div style="font-size:1.05rem; font-weight:700; letter-spacing:.01em; color:#111827; margin-bottom:8px;">
822
+ No performance yet
823
  </div>
824
+ <div style="font-size:.82rem; color:#6B7280; max-width:280px; margin:0 auto; line-height:1.7; font-family:{LABEL_FONT};">
825
+ Upload an image β€” the model reads its mood and the cat performs it,
826
+ tune and all.
827
  </div>
828
  </div>""" + _stage_close()
829
 
830
+ def loading_html(local: bool = False) -> str:
831
+ title = "Running locally…" if local else "Analyzing image…"
832
+ caption = ("on-device inference β€” first run may take a while"
833
+ if local else "choreographing the performance")
834
+ return _stage_open("#FFD21E", 2.0) + f"""
835
+ <div style="text-align:center; z-index:2; color:#6B7280; font-family:{STAGE_FONT};">
836
  <div class="loading-spinner" style="
837
+ width:32px; height:32px; margin:0 auto 16px;
838
+ border:3px solid #E5E7EB; border-top-color:#FFD21E;
839
  border-radius:50%; animation: spin 0.9s linear infinite;"></div>
840
+ <div style="font-size:.92rem; letter-spacing:.01em; color:#111827; font-weight:700;">
841
+ {title}
842
  </div>
843
+ <div style="font-size:.78rem; color:#6B7280; margin-top:4px; font-family:{LABEL_FONT};">
844
+ {caption}
845
  </div>
846
  </div>
847
  <style>@keyframes spin {{ to {{ transform: rotate(360deg); }} }}</style>""" + _stage_close()
848
 
849
  # ── Main pipeline ─────────────────────────────────────────────────────────────
850
+ def run_image_pipeline(image, prompt, model_label, max_tokens, temperature, api_key, backend):
851
+ if backend == "Local (offline)":
852
+ yield "", loading_html(local=True)
853
+ final_desc = ""
854
+ for partial in stream_description_local(image, prompt, max_tokens, temperature):
855
+ final_desc = partial
856
+ yield final_desc, loading_html(local=True)
857
+ mood, dance = get_dance_spec_local(final_desc)
858
+ yield final_desc, cat_html(mood, dance)
859
+ return
860
+
861
  final_desc = ""
862
  for partial in stream_description(image, prompt, model_label, max_tokens, temperature, api_key):
863
  final_desc = partial
 
868
  yield final_desc, cat_html(mood, dance)
869
 
870
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
871
+ # UI β€” Cat Dance Studio
872
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
873
 
874
  CSS = """
875
+ @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
876
 
877
  :root {
878
+ --bg: #FFFFFF;
879
+ --surface: #F8F9FA;
880
+ --raised: #E5E7EB;
881
+ --text: #111827;
882
+ --text-dim: #4B5563;
883
  --text-faint:#6B7280;
884
+ --accent: #FFD21E;
885
+ --accent-ink:#111827;
886
  }
887
 
888
  .gradio-container {
 
892
 
893
  /* ── Header ────────────────────────────────────────────────────────────── */
894
  #studio-header {
895
+ text-align:center; padding: 18px 20px 22px;
896
+ border:1px solid var(--raised); border-radius:12px;
897
+ background: var(--surface);
898
+ margin-bottom:8px;
899
  }
900
  #studio-header h1 {
901
  font-family:'Space Grotesk', sans-serif !important;
902
  font-weight:700 !important; letter-spacing:.01em;
903
  font-size:1.9rem !important; color:var(--text) !important;
904
+ margin-bottom:6px !important;
905
  }
906
  #studio-header p {
907
  color:var(--text-dim) !important; font-size:.92rem !important;
 
910
  #studio-header .eyebrow {
911
  display:inline-flex; align-items:center; gap:8px;
912
  font-family:'JetBrains Mono', monospace; font-size:.7rem;
913
+ letter-spacing:.18em; text-transform:uppercase;
914
+ color:var(--text-faint); margin-bottom:10px;
915
+ }
916
+ #studio-header .eyebrow .badge {
917
+ display:inline-flex; align-items:center; gap:5px;
918
+ background: var(--accent); color: var(--accent-ink);
919
+ border-radius:999px; padding:2px 10px;
920
+ font-weight:700; letter-spacing:.1em;
921
  }
922
+ #studio-header .eyebrow .badge .dot {
923
+ width:6px; height:6px; border-radius:50%;
924
+ background: var(--accent-ink); opacity:.7;
925
  }
926
 
927
  /* ── Panels ────────────────────────────────────────────────────────────── */
928
  .gr-form, .gr-box, .gr-panel, .gr-block.gr-box {
929
+ background: var(--bg) !important;
930
  border: 1px solid var(--raised) !important;
931
+ border-radius: 10px !important;
932
  }
933
 
934
  /* Section labels */
935
  .gradio-container label span {
936
  font-family:'Inter', sans-serif !important;
937
  font-size:.78rem !important; font-weight:600 !important;
938
+ letter-spacing:.02em !important; color:var(--text-dim) !important;
939
  }
940
 
941
  /* ── Buttons ───────────────────────────────────────────────────────────── */
942
  #submit-img, #submit-txt {
943
  background: var(--accent) !important;
944
+ color: var(--accent-ink) !important;
945
+ border: 1px solid #E8BD00 !important;
946
  font-weight:700 !important;
947
+ letter-spacing:.02em !important;
948
  font-family:'Space Grotesk', sans-serif !important;
949
+ box-shadow: 0 1px 2px rgba(0,0,0,.04) !important;
950
  transition: transform .12s ease, box-shadow .12s ease !important;
951
  }
952
  #submit-img:hover, #submit-txt:hover {
953
  transform: translateY(-1px);
954
+ box-shadow: 0 4px 12px rgba(255,210,30,.35) !important;
955
  }
956
  #submit-img:active, #submit-txt:active { transform: translateY(0); }
957
 
 
965
 
966
  /* ── Run-locally panel ─────────────────────────────────────────────────── */
967
  #run-locally {
968
+ border:1px solid var(--raised) !important;
969
+ background: var(--surface) !important;
970
  }
971
  #run-locally code {
972
  font-family:'JetBrains Mono', monospace !important;
 
974
  background:var(--bg) !important;
975
  border:1px solid var(--raised) !important;
976
  border-radius:6px !important;
977
+ color:#92660C !important;
978
  }
979
  #run-locally pre {
980
  background:var(--bg) !important;
 
986
  /* ── Tabs ──────────────────────────────────────────────────────────────── */
987
  .tab-nav button {
988
  font-family:'Space Grotesk', sans-serif !important;
989
+ font-weight:600 !important; letter-spacing:.01em !important;
990
  color: var(--text-dim) !important;
991
  }
992
  .tab-nav button.selected {
993
+ color: var(--text) !important;
994
+ border-bottom-color: var(--accent) !important;
995
  }
996
 
997
  /* ── Misc ──────────────────────────────────────────────────────────────── */
 
1023
 
1024
  The app checks `MINICPM_API_KEY` first, then the **API Key** field below,
1025
  then falls back to the shared public key.
1026
+
1027
+ ---
1028
+
1029
+ ### πŸ”Œ Fully offline mode
1030
+
1031
+ Select **Local (offline)** as the Backend on the Image tab to run everything
1032
+ on-device β€” no internet needed after the first download.
1033
+
1034
+ ```bash
1035
+ pip install torch transformers accelerate
1036
+ python app_single.py
1037
+ ```
1038
+
1039
+ The first time you use the Local backend, it downloads `openbmb/MiniCPM-V-4`
1040
+ (4.1B params, Apache-2.0, ~8 GB) into `model_cache/` next to this file. Every
1041
+ run after that loads from disk only β€” no network calls.
1042
+
1043
+ To force a fresh download, delete the `model_cache/` folder.
1044
+
1045
+ A GPU is recommended but not required; the app automatically uses CUDA if
1046
+ available and falls back to CPU otherwise.
1047
  """
1048
 
1049
+ with gr.Blocks(title="An Adventure in Thousand Token Wood Β· MiniCPM-V 4.6", theme=gr.themes.Soft(), css=CSS) as demo:
1050
 
1051
  gr.HTML(
1052
  """<div id="studio-header">
1053
+ <div class="eyebrow">
1054
+ <span class="badge"><span class="dot"></span>MiniCPM-V 4.6</span>
1055
+ <span>live choreography &amp; generative score</span>
1056
+ </div>
1057
+ <h1>An Adventure in Thousand Token Wood</h1>
1058
+ <p>Upload an image. The model reads its mood β€” then a cat performs it, live, with its own tune.</p>
1059
  </div>"""
1060
  )
1061
 
 
1066
  with gr.Column(scale=1):
1067
  image_input = gr.Image(type="pil", label="Upload image", height=240)
1068
  prompt_input = gr.Textbox(value=DEFAULT_PROMPT, label="Prompt", lines=2)
1069
+
1070
+ backend_sel = gr.Radio(
1071
+ choices=["API (online)", "Local (offline)"],
1072
+ value="API (online)",
1073
+ label="Backend",
1074
+ )
1075
+
1076
  model_sel = gr.Radio(choices=list(MODELS.keys()),
1077
+ value=list(MODELS.keys())[0], label="Model",
1078
+ info="Used only for the API backend")
1079
+
1080
  with gr.Accordion("Generation settings", open=False):
1081
  max_tok = gr.Slider(64, 2048, value=DEFAULT_MAX_TOKENS, step=64, label="Max tokens")
1082
  temp = gr.Slider(0.0, 1.5, value=DEFAULT_TEMPERATURE, step=0.05, label="Temperature")
1083
+
1084
  with gr.Accordion("API key", open=False):
1085
  api_key = gr.Textbox(label="Your key (optional)", type="password",
1086
  placeholder="sk-… leave blank to use the shared key")
1087
  gr.Markdown("Get your own at [modelbest.cn](https://modelbest.cn) β€” see **Run locally** below for setup.")
1088
+
1089
+ with gr.Accordion("Local model (offline)", open=False, elem_id="local-model"):
1090
+ local_status = gr.Markdown(local_status_md())
1091
+ gr.Markdown(
1092
+ f"Model: `{LOCAL_MODEL_ID}` Β· 4.1B params Β· Apache-2.0\n\n"
1093
+ "Selecting **Local (offline)** above will download this model "
1094
+ "the first time it's used (~8 GB, one-time, needs internet), "
1095
+ "then cache it in `model_cache/` for fully offline use afterward.\n\n"
1096
+ "Requires: `pip install torch transformers accelerate`"
1097
+ )
1098
+ refresh_local_btn = gr.Button("Refresh status", size="sm")
1099
+
1100
  img_btn = gr.Button("Start performance", variant="primary", elem_id="submit-img")
1101
  gr.Examples(examples=PROMPT_EXAMPLES, inputs=[prompt_input], label="Prompt ideas")
1102
 
 
1106
  placeholder="The model's description will stream in here…",
1107
  elem_id="desc-output")
1108
 
1109
+ pipeline_inputs = [image_input, prompt_input, model_sel, max_tok, temp, api_key, backend_sel]
1110
+
1111
  img_btn.click(
1112
  fn=run_image_pipeline,
1113
+ inputs=pipeline_inputs,
1114
  outputs=[desc_out, cat_out],
1115
  )
1116
  prompt_input.submit(
1117
  fn=run_image_pipeline,
1118
+ inputs=pipeline_inputs,
1119
  outputs=[desc_out, cat_out],
1120
  )
1121
+ refresh_local_btn.click(fn=local_status_md, outputs=[local_status])
1122
 
1123
  # ── Tab 2: Text-only (keyword dance, no API) ──────────────────────────
1124
  with gr.TabItem("✍️ Text β†’ Performance"):
requirements.txt CHANGED
@@ -50,4 +50,4 @@ typer==0.25.1
50
  typing-inspection==0.4.2
51
  typing_extensions==4.15.0
52
  tzdata==2026.2
53
- uvicorn==0.49.0
 
50
  typing-inspection==0.4.2
51
  typing_extensions==4.15.0
52
  tzdata==2026.2
53
+ uvicorn==0.49.0