angkit007 commited on
Commit
dda6bed
·
1 Parent(s): a88c0ff
Files changed (11) hide show
  1. README.md +75 -10
  2. app_single.py +793 -0
  3. config/__init__.py +21 -0
  4. config/settings.py +31 -0
  5. core/__init__.py +4 -0
  6. core/api_client.py +102 -0
  7. core/image_utils.py +27 -0
  8. main.py +19 -0
  9. ui/__init__.py +3 -0
  10. ui/app.py +80 -0
  11. ui/components.py +103 -0
README.md CHANGED
@@ -1,13 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Hackathon
3
- emoji: 🏆
4
- colorFrom: gray
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
+ # MiniCPM-V 4.6 · An Adventure in Thousand Token Wood
2
+
3
+ API-powered image description using OpenBMB's hosted MiniCPM-V 4.6.
4
+ No model download. No GPU. Works on any machine with internet access.
5
+
6
+ ---
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ pip install -r requirements.txt
12
+ python main.py
13
+ # → http://localhost:7860
14
+ ```
15
+
16
+ ---
17
+
18
+ ## File structure
19
+
20
+ ```
21
+ minicpm_app/
22
+ ├── main.py ← entry point (python main.py)
23
+ ├── requirements.txt
24
+
25
+ ├── config/
26
+ │ ├── __init__.py
27
+ │ └── settings.py ← API URL, keys, model IDs, defaults
28
+
29
+ ├── core/
30
+ │ ├── __init__.py
31
+ │ ├── api_client.py ← streaming API call + error handling
32
+ │ └── image_utils.py ← PIL → JPEG → base64 data URL
33
+
34
+ └── ui/
35
+ ├── __init__.py
36
+ ├── app.py ← Gradio layout (build_ui)
37
+ └── components.py ← individual input/output widgets
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Models
43
+
44
+ | Model | Params | Best for |
45
+ | ------------------------ | ------ | -------------------------------- |
46
+ | `MiniCPM-V-4.6-Instruct` | 1.3 B | Fast Q&A, direct descriptions |
47
+ | `MiniCPM-V-4.6-Thinking` | 1.3 B | Complex reasoning, OCR, analysis |
48
+
49
+ Both are well under the 32 B parameter cap.
50
+
51
  ---
52
+
53
+ ## API key
54
+
55
+ A **free public key** is baked into `config/settings.py` — works immediately, no signup.
56
+
57
+ For higher rate limits, get a personal key at https://modelbest.cn and either:
58
+
59
+ - Paste it in the **API Key** field in the UI, or
60
+ - Set the environment variable:
61
+ ```bash
62
+ export MINICPM_API_KEY=sk-your-key-here
63
+ python main.py
64
+ ```
65
+
66
+ Priority: `MINICPM_API_KEY` env var → UI field → public fallback key.
67
+
68
  ---
69
 
70
+ ## Extending
71
+
72
+ | Task | File to edit |
73
+ | ---------------------------- | ---------------------------------------- |
74
+ | Change model / add new model | `config/settings.py` → `MODELS` dict |
75
+ | Adjust defaults | `config/settings.py` → `DEFAULT_*` |
76
+ | Change API endpoint | `config/settings.py` → `API_BASE_URL` |
77
+ | Add retry logic / logging | `core/api_client.py` |
78
+ | Add new UI widgets | `ui/components.py` + wire in `ui/app.py` |
app_single.py ADDED
@@ -0,0 +1,793 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
15
+ speed : animation cycle seconds (0.3 fast … 3.0 slow)
16
+ jump : vertical bounce px (0 … 60)
17
+ sway : body rotation degrees (0 … 20)
18
+ tail_speed : tail cycle seconds (0.2 … 3.0)
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
25
+ → open http://localhost:7860
26
+
27
+ Optional: set your own API key so you're not on the shared public quota
28
+ Windows (PowerShell): $env:MINICPM_API_KEY="sk-..."
29
+ macOS / Linux: export MINICPM_API_KEY="sk-..."
30
+ """
31
+
32
+ import base64, io, os, json, re
33
+ import gradio as gr
34
+ from openai import OpenAI, APIStatusError, APIConnectionError
35
+ from PIL import Image
36
+
37
+ # ── Config ────────────────────────────────────────────────────────────────────
38
+ API_BASE_URL = "https://api.modelbest.cn/v1"
39
+ PUBLIC_API_KEY = "sk-pQ8L2zF3XmR5kY9wV4jB7hN1tC6vM0xG3aD5sH2bJ9lK4cZ8"
40
+
41
+ MODELS = {
42
+ "⚡ Instruct (fast, direct)": "MiniCPM-V-4.6-Instruct",
43
+ "🧠 Thinking (reasons first)": "MiniCPM-V-4.6-Thinking",
44
+ }
45
+
46
+ DEFAULT_PROMPT = "Describe this image in detail."
47
+ DEFAULT_MAX_TOKENS = 512
48
+ DEFAULT_TEMPERATURE = 0.7
49
+ IMAGE_QUALITY = 90
50
+
51
+ MOOD_LABELS = ["happy","sad","calm","energetic","mysterious",
52
+ "romantic","tense","nostalgic","angry","neutral"]
53
+
54
+ PROMPT_EXAMPLES = [
55
+ ["Describe this image in detail."],
56
+ ["List every object you can see."],
57
+ ["What is the mood or atmosphere of this image?"],
58
+ ["What text, if any, appears in this image?"],
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) ────────────────────────
77
+ DEFAULT_DANCE = {
78
+ "happy": {"speed":0.7, "jump":50, "sway":6, "tail_speed":0.4, "tail_range":200,"ear_tilt":8},
79
+ "sad": {"speed":2.4, "jump":2, "sway":8, "tail_speed":2.5, "tail_range":30, "ear_tilt":15},
80
+ "calm": {"speed":2.8, "jump":10, "sway":2, "tail_speed":3.2, "tail_range":35, "ear_tilt":3},
81
+ "energetic": {"speed":0.3, "jump":30, "sway":15, "tail_speed":0.28,"tail_range":180,"ear_tilt":15},
82
+ "mysterious": {"speed":2.0, "jump":15, "sway":5, "tail_speed":1.8, "tail_range":100,"ear_tilt":5},
83
+ "romantic": {"speed":1.6, "jump":12, "sway":5, "tail_speed":1.6, "tail_range":65, "ear_tilt":3},
84
+ "tense": {"speed":0.4, "jump":3, "sway":3, "tail_speed":0.4, "tail_range":10, "ear_tilt":12},
85
+ "nostalgic": {"speed":2.2, "jump":6, "sway":6, "tail_speed":2.0, "tail_range":65, "ear_tilt":5},
86
+ "angry": {"speed":0.38,"jump":18, "sway":5, "tail_speed":0.32,"tail_range":160,"ear_tilt":20},
87
+ "neutral": {"speed":2.0, "jump":8, "sway":1, "tail_speed":2.2, "tail_range":30, "ear_tilt":2},
88
+ }
89
+
90
+ # ── Helpers ───────────────────────────────────────────────────────────────────
91
+ def pil_to_data_url(image):
92
+ image = image.convert("RGB")
93
+ buf = io.BytesIO()
94
+ image.save(buf, format="JPEG", quality=IMAGE_QUALITY)
95
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
96
+
97
+ def _resolve_key(ui_key):
98
+ return (os.environ.get("MINICPM_API_KEY","").strip()
99
+ or (ui_key or "").strip() or PUBLIC_API_KEY)
100
+
101
+ def _client(ui_key):
102
+ return OpenAI(api_key=_resolve_key(ui_key), base_url=API_BASE_URL)
103
+
104
+ # ── Description (streaming) ───────────────────────────────────────────────────
105
+ def stream_description(image, prompt, model_label, max_tokens, temperature, api_key):
106
+ if image is None:
107
+ yield "⚠️ Please upload an image first."
108
+ return
109
+ try:
110
+ stream = _client(api_key).chat.completions.create(
111
+ model=MODELS[model_label],
112
+ messages=[{"role":"user","content":[
113
+ {"type":"image_url","image_url":{"url": pil_to_data_url(image)}},
114
+ {"type":"text","text": prompt},
115
+ ]}],
116
+ max_tokens=max_tokens, temperature=temperature, stream=True,
117
+ )
118
+ result = ""
119
+ for chunk in stream:
120
+ delta = chunk.choices[0].delta.content or ""
121
+ if delta:
122
+ result += delta
123
+ yield result
124
+ except APIStatusError as e:
125
+ yield f"❌ API error {e.status_code}: {e.message}"
126
+ except APIConnectionError:
127
+ yield "❌ Cannot reach api.modelbest.cn"
128
+ except Exception as e:
129
+ yield f"❌ {e}"
130
+
131
+ # ── Model-driven dance spec ───────────────────────────────────────────────────
132
+ DANCE_SYSTEM_PROMPT = f"""You are a cat dance choreographer AI.
133
+ Given a scene description, return ONLY a valid JSON object — no prose, no markdown, no code fences.
134
+
135
+ JSON schema (all fields required):
136
+ {{
137
+ "mood": one of {MOOD_LABELS},
138
+ "speed": float 0.3–3.0 (animation cycle seconds; lower = faster),
139
+ "jump": int 0–60 (vertical bounce in pixels),
140
+ "sway": int 0–20 (body rotation degrees),
141
+ "tail_speed": float 0.2–3.0 (tail cycle seconds),
142
+ "tail_range": int 5–200 (tail swing degrees),
143
+ "ear_tilt": int 0–25 (ear tilt degrees)
144
+ }}
145
+
146
+ Choose values that physically match the scene mood. An energetic scene should have
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).
153
+ The model outputs the full dance spec as JSON.
154
+ Falls back to defaults if parsing fails.
155
+ """
156
+ if not description or description.startswith(("⚠️","❌")):
157
+ return "neutral", DEFAULT_DANCE["neutral"]
158
+ try:
159
+ resp = _client(api_key).chat.completions.create(
160
+ model="MiniCPM-V-4.6-Instruct",
161
+ messages=[
162
+ {"role":"system","content": DANCE_SYSTEM_PROMPT},
163
+ {"role":"user", "content": f"Scene description:\n{description[:800]}"},
164
+ ],
165
+ max_tokens=120, temperature=0.3,
166
+ )
167
+ raw = resp.choices[0].message.content.strip()
168
+ # Strip markdown fences if present
169
+ raw = re.sub(r"```[a-z]*", "", raw).strip().strip("`").strip()
170
+ spec = json.loads(raw)
171
+
172
+ mood = spec.get("mood","neutral")
173
+ if mood not in MOOD_LABELS:
174
+ mood = "neutral"
175
+
176
+ dance = {
177
+ "speed": float(max(0.3, min(3.0, spec.get("speed", 1.5)))),
178
+ "jump": int(max(0, min(60, spec.get("jump", 10)))),
179
+ "sway": int(max(0, min(20, spec.get("sway", 5)))),
180
+ "tail_speed": float(max(0.2, min(3.0, spec.get("tail_speed", 1.5)))),
181
+ "tail_range": int(max(5, min(200, spec.get("tail_range", 40)))),
182
+ "ear_tilt": int(max(0, min(25, spec.get("ear_tilt", 5)))),
183
+ }
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()
209
+ mood = "neutral"
210
+ for m, kws in [
211
+ ("happy",["happy","celebrate","party","joy","cheerful"]),
212
+ ("sad",["sad","lonely","rain","grief","sorrow"]),
213
+ ("energetic",["energy","dance","excited","lively"]),
214
+ ("calm",["calm","peace","serene","gentle","quiet"]),
215
+ ("mysterious",["mysterious","eerie","dark","shadow"]),
216
+ ("romantic",["romantic","love","tender","warm"]),
217
+ ("tense",["tense","nervous","anxiety","fear"]),
218
+ ("nostalgic",["nostalgic","memory","vintage","old"]),
219
+ ("angry",["angry","furious","rage","fierce"]),
220
+ ]:
221
+ if any(w in t for w in kws):
222
+ mood = m
223
+ break
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
+
296
+ def _stage_close() -> str:
297
+ return "</div>"
298
+
299
+ # ── Cat stage — all parts stay inside the stage, nothing can overflow ─────────
300
+ def cat_html(mood: str, dance: dict) -> str:
301
+ p = MOOD_PALETTE.get(mood, MOOD_PALETTE["neutral"])
302
+ B = p["body"]; D = p["detail"]; E = p["eye"]; N = p["nose"]
303
+ sp = dance["speed"]; jp = dance["jump"]
304
+ sw = dance["sway"]; tsp = dance["tail_speed"]
305
+ tr = dance["tail_range"]; et = dance["ear_tilt"]
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>'
312
+ f'<span class="cue-chip">jump <b>{jp}px</b></span>'
313
+ f'<span class="cue-chip">sway <b>{sw}°</b></span>'
314
+ f'<span class="cue-chip">tail <b>{tsp}s / {tr}°</b></span>'
315
+ f'<span class="cue-chip">ears <b>{et}°</b></span>'
316
+ )
317
+
318
+ return _stage_open(B, breathe) + f"""
319
+ <style>
320
+ @keyframes K_body {{
321
+ 0%,100% {{ transform: translateY(0px) rotate(-{sw}deg); }}
322
+ 50% {{ transform: translateY(-{jp}px) rotate({sw}deg); }}
323
+ }}
324
+ @keyframes K_tail {{
325
+ 0%,100% {{ transform: rotate({t0}deg); }}
326
+ 50% {{ transform: rotate({t1}deg); }}
327
+ }}
328
+ @keyframes K_ear {{
329
+ 0%,100% {{ transform: rotate(-{et}deg); }}
330
+ 50% {{ transform: rotate({et}deg); }}
331
+ }}
332
+ @keyframes K_blink {{
333
+ 0%,88%,100% {{ transform: scaleY(1); }}
334
+ 93% {{ transform: scaleY(0.08); }}
335
+ }}
336
+ @keyframes K_shadow {{
337
+ 0%,100% {{ transform: translateX(-50%) scaleX(1); opacity:.45; }}
338
+ 50% {{ transform: translateX(-50%) scaleX({max(0.4, 1 - jp/80):.2f}); opacity:.15; }}
339
+ }}
340
+ @keyframes K_part {{
341
+ 0% {{ opacity:0; transform:translate(0,0) scale(.5); }}
342
+ 20% {{ opacity:.9; }}
343
+ 80% {{ opacity:.4; }}
344
+ 100% {{ opacity:0; transform:translate(var(--px),var(--py)) scale(1.5); }}
345
+ }}
346
+
347
+ .cat-wrap {{ position:relative; width:160px; height:200px; z-index:2; }}
348
+
349
+ .cat-shadow {{
350
+ position:absolute; bottom:-4px; left:50%;
351
+ width:72px; height:11px; border-radius:50%;
352
+ background:rgba(0,0,0,.55);
353
+ animation: K_shadow {sp}s ease-in-out infinite;
354
+ }}
355
+
356
+ .cat-unit {{
357
+ position:absolute; bottom:0; left:50%;
358
+ transform-origin: center bottom;
359
+ animation: K_body {sp}s ease-in-out infinite;
360
+ }}
361
+
362
+ .c-body {{
363
+ position:absolute; bottom:0; left:-36px;
364
+ width:72px; height:62px;
365
+ border-radius:52% 52% 46% 46%;
366
+ background:{B};
367
+ box-shadow:inset -6px -5px 0 {D};
368
+ }}
369
+ .c-belly {{
370
+ position:absolute; bottom:5px; left:50%; transform:translateX(-50%);
371
+ width:40px; height:30px; border-radius:50%;
372
+ background:{D}28;
373
+ }}
374
+
375
+ .c-tail {{
376
+ position:absolute; bottom:4px; left:22px;
377
+ width:16px; height:52px;
378
+ border-radius:38% 62% 55% 45% / 28% 28% 72% 72%;
379
+ background:{B};
380
+ box-shadow:inset 3px 0 0 {D};
381
+ transform-origin:bottom center;
382
+ animation:K_tail {tsp}s ease-in-out infinite;
383
+ }}
384
+ .c-tail::after {{
385
+ content:'';
386
+ position:absolute; top:-9px; left:-5px;
387
+ width:26px; height:18px; border-radius:50%;
388
+ background:{B};
389
+ box-shadow:inset 2px -2px 0 {D};
390
+ }}
391
+
392
+ .c-paw-l,.c-paw-r {{
393
+ position:absolute; bottom:0;
394
+ width:22px; height:13px;
395
+ border-radius:50% 50% 42% 42%;
396
+ background:{B};
397
+ box-shadow:inset -2px -2px 0 {D};
398
+ }}
399
+ .c-paw-l {{ left:-34px; }}
400
+ .c-paw-r {{ left:12px; }}
401
+
402
+ .c-head {{
403
+ position:absolute; bottom:56px; left:-32px;
404
+ width:64px; height:58px; border-radius:50%;
405
+ background:{B};
406
+ box-shadow:inset -4px -3px 0 {D};
407
+ overflow:visible;
408
+ }}
409
+
410
+ .c-ear-l,.c-ear-r {{
411
+ position:absolute;
412
+ width:0; height:0;
413
+ border-left:11px solid transparent;
414
+ border-right:11px solid transparent;
415
+ border-bottom:21px solid {B};
416
+ animation:K_ear {sp}s ease-in-out infinite;
417
+ }}
418
+ .c-ear-l {{ top:-16px; left:2px; transform-origin:bottom left; }}
419
+ .c-ear-r {{ top:-16px; left:40px; transform-origin:bottom right; }}
420
+ .c-ear-l::after,.c-ear-r::after {{
421
+ content:'';position:absolute;top:5px;left:-6px;
422
+ width:0;height:0;
423
+ border-left:6px solid transparent;
424
+ border-right:6px solid transparent;
425
+ border-bottom:13px solid {D};
426
+ }}
427
+
428
+ .c-eye-l,.c-eye-r {{
429
+ position:absolute;
430
+ width:12px; height:12px; border-radius:50%;
431
+ background:{E};
432
+ animation:K_blink 3.5s ease-in-out infinite;
433
+ }}
434
+ .c-eye-l {{ top:18px; left:8px; }}
435
+ .c-eye-r {{ top:18px; left:44px; animation-delay:.2s; }}
436
+ .c-eye-l::after,.c-eye-r::after {{
437
+ content:'';position:absolute;top:2px;left:2px;
438
+ width:5px;height:5px;border-radius:50%;
439
+ background:rgba(255,255,255,.32);
440
+ }}
441
+
442
+ .c-nose {{
443
+ position:absolute; top:32px; left:27px;
444
+ width:10px; height:7px;
445
+ border-radius:50% 50% 40% 40%;
446
+ background:{N};
447
+ transform:translateX(-50%);
448
+ }}
449
+
450
+ .c-mouth-l,.c-mouth-r {{
451
+ position:absolute;
452
+ width:8px; height:5px;
453
+ border:0 solid {N};
454
+ border-bottom-width:1.5px;
455
+ border-radius:0 0 50% 50%;
456
+ top:38px;
457
+ }}
458
+ .c-mouth-l {{ left:21px; border-left-width:1.5px; transform:rotate(10deg); }}
459
+ .c-mouth-r {{ left:30px; border-right-width:1.5px; transform:rotate(-10deg); }}
460
+
461
+ .c-wl1,.c-wl2,.c-wr1,.c-wr2 {{
462
+ position:absolute; height:1.5px;
463
+ background:rgba(255,255,255,.5); border-radius:1px;
464
+ width:28px;
465
+ }}
466
+ .c-wl1 {{ top:29px; right:37px; transform:rotate(-10deg); transform-origin:right; }}
467
+ .c-wl2 {{ top:35px; right:37px; transform:rotate( 10deg); transform-origin:right; }}
468
+ .c-wr1 {{ top:29px; left:37px; transform:rotate( 10deg); transform-origin:left; }}
469
+ .c-wr2 {{ top:35px; left:37px; transform:rotate(-10deg); transform-origin:left; }}
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
+ }}
478
+ </style>
479
+
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">
489
+ <div class="c-tail"></div>
490
+ <div class="c-body"><div class="c-belly"></div></div>
491
+ <div class="c-paw-l"></div>
492
+ <div class="c-paw-r"></div>
493
+ <div class="c-head">
494
+ <div class="c-ear-l"></div>
495
+ <div class="c-ear-r"></div>
496
+ <div class="c-eye-l"></div>
497
+ <div class="c-eye-r"></div>
498
+ <div class="c-nose"></div>
499
+ <div class="c-mouth-l"></div>
500
+ <div class="c-mouth-r"></div>
501
+ <div class="c-wl1"></div>
502
+ <div class="c-wl2"></div>
503
+ <div class="c-wr1"></div>
504
+ <div class="c-wr2"></div>
505
+ </div>
506
+ </div>
507
+ </div>
508
+
509
+ <div class="stage-caption">{p['caption']}</div>
510
+ <div class="cue-sheet">{cue_chips}</div>
511
+
512
+ <script>
513
+ (function(){{
514
+ const wrap = document.getElementById('cw');
515
+ const chars = '{p['particle']}'.split('');
516
+ for(let i=0;i<22;i++){{
517
+ const el = document.createElement('div');
518
+ el.className = 'c-particle';
519
+ el.textContent = chars[i % chars.length];
520
+ const a = Math.random()*Math.PI*2, d = 50+Math.random()*75;
521
+ el.style.setProperty('--px', (Math.cos(a)*d)+'px');
522
+ el.style.setProperty('--py', (Math.sin(a)*d-20)+'px');
523
+ el.style.setProperty('--pd', (.9+Math.random()*2).toFixed(2)+'s');
524
+ el.style.setProperty('--pde',(Math.random()*2.5).toFixed(2)+'s');
525
+ el.style.left = (55+Math.random()*50)+'px';
526
+ el.style.top = (40+Math.random()*80)+'px';
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
566
+ yield partial, loading_html()
567
+
568
+ # Model determines the full dance spec
569
+ mood, dance = get_dance_spec(final_desc, api_key)
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 {
590
+ background: var(--bg) !important;
591
+ font-family: 'Inter', system-ui, sans-serif !important;
592
+ }
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;
606
+ margin:0 !important;
607
+ }
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
+
649
+ /* ── Description output ───────────────────────────────────────────────── */
650
+ #desc-output textarea {
651
+ font-family:'Inter', sans-serif !important;
652
+ font-size:.88rem !important; line-height:1.6 !important;
653
+ color:var(--text) !important;
654
+ background:var(--surface) !important;
655
+ }
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;
664
+ font-size:.78rem !important;
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;
672
+ border:1px solid var(--raised) !important;
673
+ border-radius:8px !important;
674
+ padding:10px 14px !important;
675
+ }
676
+
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 ──────────────────────────────────────────────────────────────── */
688
+ footer { display:none !important; }
689
+ .gr-accordion { border-color: var(--raised) !important; }
690
+ """
691
+
692
+ LOCAL_RUN_MD = """
693
+ **Run this studio on your own machine** — no install beyond Python.
694
+
695
+ ```bash
696
+ pip install gradio openai pillow
697
+ python app_single.py
698
+ ```
699
+
700
+ Then open **http://localhost:7860**
701
+
702
+ By default the app uses a shared public API key (rate-limited). To use your
703
+ own [modelbest.cn](https://modelbest.cn) key without typing it every time,
704
+ set an environment variable before launching:
705
+
706
+ ```bash
707
+ # macOS / Linux
708
+ export MINICPM_API_KEY="sk-your-key-here"
709
+
710
+ # Windows (PowerShell)
711
+ $env:MINICPM_API_KEY="sk-your-key-here"
712
+ ```
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
+
728
+ with gr.Tabs():
729
+ # ── Tab 1: Image pipeline ─────────────────────────────────────────────
730
+ with gr.TabItem("📷 Image → Performance"):
731
+ with gr.Row():
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
+
747
+ with gr.Column(scale=1):
748
+ cat_out = gr.HTML(value=placeholder_html(), label="Stage")
749
+ desc_out = gr.Textbox(label="Description (model output, streaming)", lines=7,
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"):
766
+ gr.Markdown("Type mood words for an instant performance — no API key needed.")
767
+ with gr.Row():
768
+ with gr.Column(scale=1):
769
+ txt_input = gr.Textbox(
770
+ label="Describe a mood",
771
+ placeholder='"happy party" · "sad rain" · "energetic dance"',
772
+ lines=3,
773
+ )
774
+ txt_btn = gr.Button("Start performance", variant="primary", elem_id="submit-txt")
775
+ gr.Examples(
776
+ examples=[["happy celebrate joy"],["sad lonely rain"],
777
+ ["energetic dance excited"],["calm peaceful"],
778
+ ["mysterious dark shadow"],["romantic love"],
779
+ ["tense nervous fear"],["nostalgic memory"],["angry rage"]],
780
+ inputs=[txt_input], label="Quick examples",
781
+ )
782
+ with gr.Column(scale=1):
783
+ txt_cat = gr.HTML(value=placeholder_html(), label="Stage")
784
+
785
+ txt_btn.click(fn=generate_animation, inputs=[txt_input], outputs=[txt_cat])
786
+ txt_input.submit(fn=generate_animation, inputs=[txt_input], outputs=[txt_cat])
787
+
788
+ # ── Run locally ──────────────────────────────────────────────────────────
789
+ with gr.Accordion("⚙ Run locally", open=False, elem_id="run-locally"):
790
+ gr.Markdown(LOCAL_RUN_MD)
791
+
792
+ if __name__ == "__main__":
793
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
config/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .settings import (
2
+ API_BASE_URL,
3
+ PUBLIC_API_KEY,
4
+ MODELS,
5
+ MODEL_PARAMS,
6
+ DEFAULT_PROMPT,
7
+ DEFAULT_MAX_TOKENS,
8
+ DEFAULT_TEMPERATURE,
9
+ IMAGE_QUALITY,
10
+ )
11
+
12
+ __all__ = [
13
+ "API_BASE_URL",
14
+ "PUBLIC_API_KEY",
15
+ "MODELS",
16
+ "MODEL_PARAMS",
17
+ "DEFAULT_PROMPT",
18
+ "DEFAULT_MAX_TOKENS",
19
+ "DEFAULT_TEMPERATURE",
20
+ "IMAGE_QUALITY",
21
+ ]
config/settings.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config/settings.py — all constants in one place.
3
+ Change values here; nothing else needs editing.
4
+ """
5
+
6
+ # ── API ───────────────────────────────────────────────────────────────────────
7
+ API_BASE_URL = "https://api.modelbest.cn/v1"
8
+
9
+ # Free public key provided by OpenBMB — rate-limited, no signup.
10
+ # Set env var MINICPM_API_KEY to override at runtime without touching code.
11
+ PUBLIC_API_KEY = "sk-pQ8L2zF3XmR5kY9wV4jB7hN1tC6vM0xG3aD5sH2bJ9lK4cZ8"
12
+
13
+ # ── Models ────────────────────────────────────────────────────────────────────
14
+ # Both are 1.3 B parameters — well under the 32 B cap.
15
+ MODELS: dict[str, str] = {
16
+ "⚡ Instruct (fast, direct)": "MiniCPM-V-4.6-Instruct",
17
+ "🧠 Thinking (reasons first)": "MiniCPM-V-4.6-Thinking",
18
+ }
19
+
20
+ MODEL_PARAMS: dict[str, str] = {
21
+ "MiniCPM-V-4.6-Instruct": "1.3 B",
22
+ "MiniCPM-V-4.6-Thinking": "1.3 B",
23
+ }
24
+
25
+ # ── Generation defaults ───────────────────────────────────────────────────────
26
+ DEFAULT_PROMPT = "Describe this image in detail."
27
+ DEFAULT_MAX_TOKENS = 512
28
+ DEFAULT_TEMPERATURE = 0.7
29
+
30
+ # ── Image encoding ────────────────────────────────────────────────────────────
31
+ IMAGE_QUALITY = 90 # JPEG quality sent to the API (0-100)
core/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .api_client import stream_description
2
+ from .image_utils import pil_to_data_url
3
+
4
+ __all__ = ["stream_description", "pil_to_data_url"]
core/api_client.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/api_client.py — MiniCPM-V API wrapper.
3
+
4
+ Handles:
5
+ • OpenAI-compatible streaming via the `openai` SDK
6
+ • API key resolution (env var → UI field → public fallback)
7
+ • Friendly error messages for 401 / 429 / network failures
8
+ """
9
+
10
+ import os
11
+ from collections.abc import Generator
12
+
13
+ from openai import OpenAI, APIStatusError, APIConnectionError
14
+ from PIL import Image
15
+
16
+ from config import API_BASE_URL, PUBLIC_API_KEY, MODELS
17
+ from core.image_utils import pil_to_data_url
18
+
19
+
20
+ def _resolve_key(ui_key: str) -> str:
21
+ """
22
+ Priority: env var MINICPM_API_KEY → UI field → hardcoded public key.
23
+ """
24
+ return (
25
+ os.environ.get("MINICPM_API_KEY", "").strip()
26
+ or (ui_key or "").strip()
27
+ or PUBLIC_API_KEY
28
+ )
29
+
30
+
31
+ def stream_description(
32
+ image: Image.Image,
33
+ prompt: str,
34
+ model_label: str,
35
+ max_tokens: int,
36
+ temperature: float,
37
+ api_key: str,
38
+ ) -> Generator[str, None, None]:
39
+ """
40
+ Stream the model's response token-by-token.
41
+
42
+ Yields:
43
+ Cumulative response string — each yield is the full text so far,
44
+ so Gradio can update the textbox incrementally.
45
+ """
46
+ if image is None:
47
+ yield "⚠️ Please upload an image first."
48
+ return
49
+
50
+ key = _resolve_key(api_key)
51
+ model_id = MODELS[model_label]
52
+ data_url = pil_to_data_url(image)
53
+
54
+ client = OpenAI(api_key=key, base_url=API_BASE_URL)
55
+
56
+ try:
57
+ stream = client.chat.completions.create(
58
+ model=model_id,
59
+ messages=[{
60
+ "role": "user",
61
+ "content": [
62
+ {"type": "image_url", "image_url": {"url": data_url}},
63
+ {"type": "text", "text": prompt},
64
+ ],
65
+ }],
66
+ max_tokens=max_tokens,
67
+ temperature=temperature,
68
+ stream=True,
69
+ )
70
+
71
+ result = ""
72
+ for chunk in stream:
73
+ delta = chunk.choices[0].delta.content or ""
74
+ if delta:
75
+ result += delta
76
+ yield result
77
+
78
+ except APIStatusError as e:
79
+ if e.status_code == 401:
80
+ yield (
81
+ "🔐 **API key rejected (401).**\n\n"
82
+ "The public key may have hit its rate limit. \n"
83
+ "Get a free personal key at https://modelbest.cn and paste it "
84
+ "in the **API Key** field."
85
+ )
86
+ elif e.status_code == 429:
87
+ yield (
88
+ "⏳ **Rate limit reached (429).**\n\n"
89
+ "The free public key is shared — wait a moment and retry, "
90
+ "or use your own key from https://modelbest.cn"
91
+ )
92
+ else:
93
+ yield f"❌ API error {e.status_code}:\n\n```\n{e.message}\n```"
94
+
95
+ except APIConnectionError:
96
+ yield (
97
+ "❌ **Cannot reach the API** (`api.modelbest.cn`). \n"
98
+ "Check your internet connection and try again."
99
+ )
100
+
101
+ except Exception as e:
102
+ yield f"❌ Unexpected error:\n\n```\n{e}\n```"
core/image_utils.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/image_utils.py — image encoding helpers.
3
+ """
4
+
5
+ import base64
6
+ import io
7
+
8
+ from PIL import Image
9
+
10
+ from config import IMAGE_QUALITY
11
+
12
+
13
+ def pil_to_data_url(image: Image.Image) -> str:
14
+ """
15
+ Convert a PIL image to a JPEG base64 data URL suitable for the API.
16
+
17
+ Args:
18
+ image: Any PIL Image (RGB, RGBA, P, …).
19
+
20
+ Returns:
21
+ String of the form 'data:image/jpeg;base64,<b64>'
22
+ """
23
+ image = image.convert("RGB")
24
+ buf = io.BytesIO()
25
+ image.save(buf, format="JPEG", quality=IMAGE_QUALITY)
26
+ b64 = base64.b64encode(buf.getvalue()).decode()
27
+ return f"data:image/jpeg;base64,{b64}"
main.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py — entry point
3
+ Run with: python main.py
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # Ensure the project root is on sys.path so `core`, `ui`, `config` are importable
10
+ # regardless of where Python is invoked from.
11
+ ROOT = Path(__file__).resolve().parent
12
+ if str(ROOT) not in sys.path:
13
+ sys.path.insert(0, str(ROOT))
14
+
15
+ from ui.app import build_ui
16
+
17
+ if __name__ == "__main__":
18
+ demo = build_ui()
19
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
ui/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .app import build_ui
2
+
3
+ __all__ = ["build_ui"]
ui/app.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui/app.py — Gradio layout.
3
+
4
+ This file is pure layout — no inference logic lives here.
5
+ All intelligence is in core/api_client.py.
6
+ """
7
+
8
+ import gradio as gr
9
+
10
+ from core import stream_description
11
+ from ui.components import (
12
+ image_upload,
13
+ prompt_box,
14
+ model_selector,
15
+ generation_settings,
16
+ api_key_field,
17
+ prompt_examples,
18
+ output_box,
19
+ info_panel,
20
+ )
21
+
22
+ CSS = """
23
+ #title { text-align: center; }
24
+ #submit { background: #6366f1; color: white; }
25
+ #submit:hover { background: #4f46e5; }
26
+ #model-hint { font-size: 0.85em; opacity: 0.75; }
27
+ footer { display: none !important; }
28
+ """
29
+
30
+
31
+ def build_ui() -> gr.Blocks:
32
+ with gr.Blocks(
33
+ title="MiniCPM-V · API Image Describer",
34
+ theme=gr.themes.Soft(),
35
+ css=CSS,
36
+ ) as demo:
37
+
38
+ gr.Markdown(
39
+ "# 🔍 MiniCPM-V 4.6 · Image Description via API\n"
40
+ "**No downloads. No GPU needed.** Calls OpenBMB's hosted API — "
41
+ "results stream back in real time.\n\n"
42
+ "> Models: `MiniCPM-V-4.6-Instruct` & `MiniCPM-V-4.6-Thinking` · "
43
+ "1.3 B params each · well under the 32 B cap · Apache-2.0",
44
+ elem_id="title",
45
+ )
46
+
47
+ with gr.Row():
48
+ # ── Left column — inputs ──────────────────────────────────────────
49
+ with gr.Column(scale=1):
50
+ image = image_upload()
51
+ prompt = prompt_box()
52
+ model = model_selector()
53
+
54
+ gr.Markdown(
55
+ "**Instruct** — direct, concise answer. \n"
56
+ "**Thinking** — reasons step-by-step first (slower, more thorough).",
57
+ elem_id="model-hint",
58
+ )
59
+
60
+ max_tokens, temperature = generation_settings()
61
+ api_key = api_key_field()
62
+
63
+ submit = gr.Button(
64
+ "▶ Describe Image", variant="primary", elem_id="submit"
65
+ )
66
+ prompt_examples(prompt)
67
+
68
+ # ── Right column — output ─────────────────────────────────────────
69
+ with gr.Column(scale=1):
70
+ output = output_box()
71
+ info_panel()
72
+
73
+ # ── Event wiring ──────────────────────────────────────────────────────
74
+ inputs = [image, prompt, model, max_tokens, temperature, api_key]
75
+ outputs = [output]
76
+
77
+ submit.click(fn=stream_description, inputs=inputs, outputs=outputs)
78
+ prompt.submit(fn=stream_description, inputs=inputs, outputs=outputs)
79
+
80
+ return demo
ui/components.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui/components.py — reusable Gradio input widgets.
3
+
4
+ Each function returns (component, …) tuples so app.py stays declarative.
5
+ """
6
+
7
+ import gradio as gr
8
+
9
+ from config import (
10
+ MODELS,
11
+ DEFAULT_PROMPT,
12
+ DEFAULT_MAX_TOKENS,
13
+ DEFAULT_TEMPERATURE,
14
+ )
15
+
16
+ PROMPT_EXAMPLES = [
17
+ ["Describe this image in detail."],
18
+ ["List every object you can see."],
19
+ ["What is the mood or atmosphere of this image?"],
20
+ ["What text, if any, appears in this image?"],
21
+ ["Explain this image to someone who cannot see it."],
22
+ ["What is unusual or interesting about this image?"],
23
+ ]
24
+
25
+
26
+ def image_upload() -> gr.Image:
27
+ return gr.Image(type="pil", label="📷 Input Image", height=320)
28
+
29
+
30
+ def prompt_box() -> gr.Textbox:
31
+ return gr.Textbox(
32
+ value=DEFAULT_PROMPT,
33
+ label="💬 Prompt",
34
+ lines=3,
35
+ placeholder="Ask anything about the image…",
36
+ )
37
+
38
+
39
+ def model_selector() -> gr.Radio:
40
+ return gr.Radio(
41
+ choices=list(MODELS.keys()),
42
+ value=list(MODELS.keys())[0],
43
+ label="🤖 Model",
44
+ )
45
+
46
+
47
+ def generation_settings() -> tuple[gr.Slider, gr.Slider]:
48
+ """Returns (max_tokens_slider, temperature_slider)."""
49
+ with gr.Accordion("⚙️ Generation settings", open=False):
50
+ max_tokens = gr.Slider(
51
+ 64, 2048, value=DEFAULT_MAX_TOKENS, step=64, label="Max tokens"
52
+ )
53
+ temperature = gr.Slider(
54
+ 0.0, 1.5, value=DEFAULT_TEMPERATURE, step=0.05, label="Temperature"
55
+ )
56
+ return max_tokens, temperature
57
+
58
+
59
+ def api_key_field() -> gr.Textbox:
60
+ with gr.Accordion("🔑 API Key", open=False):
61
+ box = gr.Textbox(
62
+ label="Your API key (optional)",
63
+ type="password",
64
+ lines=1,
65
+ placeholder="sk-… leave blank to use the free public key",
66
+ )
67
+ gr.Markdown(
68
+ "A **free public key** is used when this field is blank. \n"
69
+ "It is shared and rate-limited — paste your own key from "
70
+ "[modelbest.cn](https://modelbest.cn) for unlimited use."
71
+ )
72
+ return box
73
+
74
+
75
+ def prompt_examples(prompt_input: gr.Textbox) -> gr.Examples:
76
+ return gr.Examples(
77
+ examples=PROMPT_EXAMPLES,
78
+ inputs=[prompt_input],
79
+ label="📝 Examples",
80
+ )
81
+
82
+
83
+ def output_box() -> gr.Textbox:
84
+ return gr.Textbox(
85
+ label="📄 Model Description (streaming)",
86
+ lines=22,
87
+ placeholder="Response streams here token-by-token…",
88
+ )
89
+
90
+
91
+ def info_panel() -> None:
92
+ gr.Markdown(
93
+ "---\n"
94
+ "**API details**\n"
95
+ "- Endpoint: `https://api.modelbest.cn/v1/chat/completions`\n"
96
+ "- Protocol: OpenAI-compatible (SSE streaming)\n"
97
+ "- Auth: Bearer token\n\n"
98
+ "**Models**\n"
99
+ "| Model | Params | Best for |\n"
100
+ "|---|---|---|\n"
101
+ "| Instruct | 1.3 B | Fast Q&A, descriptions |\n"
102
+ "| Thinking | 1.3 B | Complex reasoning, OCR |\n"
103
+ )