Hug0endob commited on
Commit
d64e77d
·
verified ·
1 Parent(s): 0e1399c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +298 -93
app.py CHANGED
@@ -1,131 +1,336 @@
1
  #!/usr/bin/env python3
2
- import os
3
- from io import BytesIO
4
- from typing import Tuple
5
 
 
 
 
 
 
 
6
  import gradio as gr
7
- from PIL import Image
8
 
9
- css = """
10
- .preview_media img, .preview_media video { max-width: 100%; height: auto; border-radius: 6px; }
11
- .top_preview { display:flex; gap:12px; align-items:flex-start; flex-direction:column; }
12
- .pip_button { margin-top:6px; }
13
- """
 
 
 
14
 
15
- def _btn_label_for_status(status: str) -> str:
16
- return {
17
- "idle": "Submit",
18
- "busy": "Processing…",
19
- "done": "Submit",
20
- "error": "Retry",
21
- }.get(status or "idle", "Submit")
22
-
23
-
24
- def load_preview(url: str):
25
- empty_img = gr.update(value=None, visible=False)
26
- empty_vid = gr.update(value=None, visible=False)
27
-
28
- if not url:
29
- return empty_img, empty_vid, gr.update(visible=False)
30
-
31
- # Local file
32
- if not is_remote(url) and os.path.exists(url):
33
- ext = ext_from_src(url)
34
- if ext in VIDEO_EXTS:
35
- return empty_img, gr.update(value=os.path.abspath(url), visible=True), gr.update(visible=True)
36
- if ext in IMAGE_EXTS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  try:
38
- img = Image.open(url)
39
- if getattr(img, "is_animated", False):
40
- img.seek(0)
41
- return gr.update(value=img.convert("RGB"), visible=True), empty_vid, gr.update(visible=False)
 
 
 
 
 
 
 
 
 
42
  except Exception:
43
- return empty_img, empty_vid, gr.update(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- # Remote: check headers for content-type
46
- head = safe_head(url)
47
- if head:
48
- ctype = (head.headers.get("content-type") or "").lower()
49
- if ctype.startswith("video/") or any(url.lower().endswith(ext) for ext in VIDEO_EXTS):
50
- return empty_img, gr.update(value=url, visible=True), gr.update(visible=True)
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- # Try load as image
 
 
 
 
 
 
 
53
  try:
54
- r = safe_get(url, timeout=15)
55
- img = Image.open(BytesIO(r.content))
56
- if getattr(img, "is_animated", False):
57
- img.seek(0)
58
- return gr.update(value=img.convert("RGB"), visible=True), empty_vid, gr.update(visible=False)
59
  except Exception:
60
- return empty_img, empty_vid, gr.update(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  def create_demo():
64
- with gr.Blocks(css=css, title="Media Analysis") as demo:
65
  with gr.Row():
66
  with gr.Column(scale=1):
67
- preview_image = gr.Image(label="Preview Image", type="pil", visible=False, elem_classes="preview_media")
68
- preview_video = gr.Video(label="Preview Video", visible=False, elem_classes="preview_media")
69
- pip_button = gr.Button("Open Video in PiP", visible=False, elem_classes="pip_button")
70
- with gr.Column(scale=1):
71
  url_input = gr.Textbox(label="Image / Video URL or local path", placeholder="https://... or /path/to/file", lines=1)
72
  with gr.Accordion("Prompt (optional)", open=False):
73
  custom_prompt = gr.Textbox(label="Prompt", lines=4, value="")
74
  with gr.Accordion("Mistral API Key (optional)", open=False):
75
  api_key = gr.Textbox(label="API Key", type="password", max_lines=1)
76
- submit_btn = gr.Button(_btn_label_for_status("idle"), variant="primary")
77
  clear_btn = gr.Button("Clear")
78
- final_md = gr.Markdown(value="", label="Result")
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
- status = gr.State("idle")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  url_input.change(fn=load_preview, inputs=[url_input], outputs=[preview_image, preview_video, pip_button])
83
 
84
- def do_clear():
85
- return "", gr.update(value=None, visible=False), gr.update(value=None, visible=False), gr.update(value="", visible=True)
86
- clear_btn.click(fn=do_clear, inputs=[], outputs=[url_input, preview_image, preview_video, final_md])
 
 
 
 
 
87
 
88
  def start_busy():
89
- return "busy", gr.update(value=_btn_label_for_status("busy"), interactive=False)
 
 
90
 
91
  def worker(url: str, prompt: str, key: str, progress=gr.Progress()):
92
  return process_media(url or "", prompt or "", key or "", progress=progress)
93
-
94
- def finish(result: str):
95
- s = "done"
96
- if not result:
97
- md = "**Error:** no result returned."
98
- s = "error"
99
- elif isinstance(result, str) and result.lower().startswith("error"):
100
- md = f"**Error:** {result}"
101
- s = "error"
102
- else:
103
- md = result
104
- return s, gr.update(value=md)
105
-
106
- submit_btn.click(fn=start_busy, inputs=[], outputs=[status, submit_btn])
107
-
108
- submit_btn.click(fn=worker, inputs=[url_input, custom_prompt, api_key], outputs=[final_md], queue=True).then(
109
- fn=finish, inputs=[final_md], outputs=[status, final_md]
110
  )
111
 
112
  def btn_label_for_state(s: str):
113
- return gr.update(value=_btn_label_for_status(s), interactive=(s != "busy"))
114
- status.change(fn=btn_label_for_state, inputs=[status], outputs=[submit_btn])
115
-
116
- # PiP JS: use a JS function string without Python .format interpolation
117
- pip_js = (
118
- "const vid = document.querySelector('video');"
119
- "if (!vid) return;"
120
- "if (document.pictureInPictureElement) { document.exitPictureInPicture().catch(()=>{}); }"
121
- "else { vid.requestPictureInPicture().catch(()=>{}); }"
122
- )
123
- # Attach PiP: client-side JS (no inputs/outputs)
124
- pip_button.click(fn=None, _js=pip_js, inputs=[], outputs=[])
125
 
126
  return demo
127
 
128
-
129
  if __name__ == "__main__":
130
- demo = create_demo()
131
- demo.launch(server_name="0.0.0.0", share=False)
 
1
  #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
 
 
3
 
4
+ from __future__ import annotations
5
+ import os, shutil, subprocess, tempfile, base64, json
6
+ from io import BytesIO
7
+ from typing import List, Tuple
8
+ import requests
9
+ from PIL import Image, ImageFile, UnidentifiedImageError
10
  import gradio as gr
 
11
 
12
+ # --- Config
13
+ DEFAULT_KEY = os.getenv("MISTRAL_API_KEY", "")
14
+ PIXTRAL_MODEL = "pixtral-12b-2409"
15
+ VIDEO_MODEL = "voxtral-mini-latest"
16
+ STREAM_THRESHOLD = 20 * 1024 * 1024
17
+ FFMPEG_BIN = shutil.which("ffmpeg")
18
+ IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".gif")
19
+ VIDEO_EXTS = (".mp4", ".mov", ".webm", ".mkv", ".avi", ".flv")
20
 
21
+ SYSTEM_INSTRUCTION = (
22
+ "You are a clinical visual analyst. Only analyze media actually provided (image or video data). If video review entirely, not frame by frame. "
23
+ "Provide factual, clinical descriptions of observable features. Avoid inventing sensory information."
24
+ )
25
+
26
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
27
+ Image.MAX_IMAGE_PIXELS = 10000 * 10000
28
+
29
+ try:
30
+ from mistralai import Mistral
31
+ except Exception:
32
+ Mistral = None
33
+
34
+ def get_client(key: str | None = None):
35
+ api_key = (key or "").strip() or DEFAULT_KEY
36
+ if Mistral is None:
37
+ class Dummy:
38
+ def __init__(self, k): self.api_key = k
39
+ return Dummy(api_key)
40
+ return Mistral(api_key=api_key)
41
+
42
+ def is_remote(src: str) -> bool:
43
+ return bool(src) and src.startswith(("http://", "https://"))
44
+
45
+ def ext_from_src(src: str) -> str:
46
+ if not src: return ""
47
+ _, ext = os.path.splitext((src or "").split("?")[0])
48
+ return ext.lower()
49
+
50
+ def safe_head(url: str, timeout: int = 6):
51
+ try:
52
+ r = requests.head(url, timeout=timeout, allow_redirects=True)
53
+ return None if r.status_code >= 400 else r
54
+ except Exception:
55
+ return None
56
+
57
+ def safe_get(url: str, timeout: int = 15):
58
+ r = requests.get(url, timeout=timeout)
59
+ r.raise_for_status()
60
+ return r
61
+
62
+ def fetch_bytes(src: str, stream_threshold: int = STREAM_THRESHOLD, timeout: int = 60) -> bytes:
63
+ if is_remote(src):
64
+ head = safe_head(src)
65
+ if head is not None:
66
+ cl = head.headers.get("content-length")
67
  try:
68
+ if cl and int(cl) > stream_threshold:
69
+ with requests.get(src, timeout=timeout, stream=True) as r:
70
+ r.raise_for_status()
71
+ fd, p = tempfile.mkstemp()
72
+ os.close(fd)
73
+ try:
74
+ with open(p, "wb") as fh:
75
+ for chunk in r.iter_content(8192):
76
+ if chunk: fh.write(chunk)
77
+ with open(p, "rb") as fh: return fh.read()
78
+ finally:
79
+ try: os.remove(p)
80
+ except Exception: pass
81
  except Exception:
82
+ pass
83
+ r = safe_get(src, timeout=timeout)
84
+ return r.content
85
+ else:
86
+ with open(src, "rb") as f: return f.read()
87
+
88
+ def save_bytes_to_temp(b: bytes, suffix: str) -> str:
89
+ fd, path = tempfile.mkstemp(suffix=suffix); os.close(fd)
90
+ with open(path, "wb") as f: f.write(b)
91
+ return path
92
+
93
+ def convert_to_jpeg_bytes(img_bytes: bytes, base_h: int = 480) -> bytes:
94
+ img = Image.open(BytesIO(img_bytes))
95
+ try:
96
+ if getattr(img, "is_animated", False): img.seek(0)
97
+ except Exception: pass
98
+ if img.mode != "RGB": img = img.convert("RGB")
99
+ h = base_h
100
+ w = max(1, int(img.width * (h / img.height)))
101
+ img = img.resize((w, h), Image.LANCZOS)
102
+ buf = BytesIO(); img.save(buf, format="JPEG", quality=85)
103
+ return buf.getvalue()
104
+
105
+ def b64_bytes(b: bytes, mime: str = "image/jpeg") -> str:
106
+ return f"data:{mime};base64," + base64.b64encode(b).decode("utf-8")
107
+
108
+ def extract_best_frames_bytes(media_path: str, sample_count: int = 5, timeout_extract: int = 15) -> List[bytes]:
109
+ frames: List[bytes] = []
110
+ if not FFMPEG_BIN or not os.path.exists(media_path): return frames
111
+ timestamps = [0.5, 1.0, 2.0, 3.0, 4.0][:sample_count]
112
+ for i, t in enumerate(timestamps):
113
+ fd, tmp = tempfile.mkstemp(suffix=f"_{i}.jpg"); os.close(fd)
114
+ cmd = [FFMPEG_BIN, "-nostdin", "-y", "-ss", str(t), "-i", media_path, "-frames:v", "1", "-q:v", "2", tmp]
115
+ try:
116
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout_extract)
117
+ if os.path.exists(tmp) and os.path.getsize(tmp) > 0:
118
+ with open(tmp, "rb") as f: frames.append(f.read())
119
+ except Exception:
120
+ pass
121
+ finally:
122
+ try: os.remove(tmp)
123
+ except Exception: pass
124
+ return frames
125
+
126
+ def chat_complete(client, model: str, messages, timeout: int = 120) -> str:
127
+ try:
128
+ if hasattr(client, "chat") and hasattr(client.chat, "complete"):
129
+ res = client.chat.complete(model=model, messages=messages, stream=False)
130
+ else:
131
+ api_key = getattr(client, "api_key", "") or DEFAULT_KEY
132
+ url = "https://api.mistral.ai/v1/chat/completions"
133
+ headers = ({"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} if api_key else {"Content-Type": "application/json"})
134
+ r = requests.post(url, json={"model": model, "messages": messages}, headers=headers, timeout=timeout)
135
+ r.raise_for_status(); res = r.json()
136
+ choices = getattr(res, "choices", None) or (res.get("choices") if isinstance(res, dict) else [])
137
+ if not choices: return str(res)
138
+ first = choices[0]
139
+ msg = first.message if hasattr(first, "message") else (first.get("message") if isinstance(first, dict) else first)
140
+ content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
141
+ return content.strip() if isinstance(content, str) else str(content)
142
+ except Exception as e:
143
+ return f"Error during model call: {e}"
144
 
145
+ def upload_file_to_mistral(client, path: str, filename: str | None = None, purpose: str = "batch", timeout: int = 120) -> str:
146
+ fname = filename or os.path.basename(path)
147
+ try:
148
+ if hasattr(client, "files") and hasattr(client.files, "upload"):
149
+ with open(path, "rb") as fh:
150
+ res = client.files.upload(file={"file_name": fname, "content": fh}, purpose=purpose)
151
+ fid = getattr(res, "id", None) or (res.get("id") if isinstance(res, dict) else None)
152
+ if not fid: fid = res["data"][0]["id"]
153
+ return fid
154
+ except Exception:
155
+ pass
156
+ api_key = getattr(client, "api_key", "") or DEFAULT_KEY
157
+ url = "https://api.mistral.ai/v1/files"
158
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
159
+ with open(path, "rb") as fh:
160
+ files = {"file": (fname, fh)}; data = {"purpose": purpose}
161
+ r = requests.post(url, headers=headers, files=files, data=data, timeout=timeout); r.raise_for_status(); jr = r.json()
162
+ return jr.get("id") or jr.get("data", [{}])[0].get("id")
163
 
164
+ def analyze_image_structured(client, img_bytes: bytes, prompt: str) -> str:
165
+ jpeg = convert_to_jpeg_bytes(img_bytes, base_h=1024)
166
+ data_url = b64_bytes(jpeg, mime="image/jpeg")
167
+ messages = [{"role": "system", "content": SYSTEM_INSTRUCTION},
168
+ {"role": "user", "content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": data_url}]}]
169
+ return chat_complete(client, PIXTRAL_MODEL, messages)
170
+
171
+ def analyze_video_cohesive(client, video_path: str, prompt: str) -> str:
172
  try:
173
+ file_id = upload_file_to_mistral(client, video_path, filename=os.path.basename(video_path))
174
+ extra_msg = f"Uploaded video file id: {file_id}\n\nInstruction: Analyze the entire video and produce a single cohesive narrative describing consistent observations."
175
+ messages = [{"role": "system", "content": SYSTEM_INSTRUCTION}, {"role": "user", "content": extra_msg + "\n\n" + prompt}]
176
+ return chat_complete(client, VIDEO_MODEL, messages)
 
177
  except Exception:
178
+ frames = extract_best_frames_bytes(video_path, sample_count=6)
179
+ if not frames: return "Error: could not upload video and no frames could be extracted."
180
+ image_entries = []
181
+ for i, fb in enumerate(frames, start=1):
182
+ try:
183
+ j = convert_to_jpeg_bytes(fb, base_h=720)
184
+ image_entries.append({"type": "image_url", "image_url": b64_bytes(j, mime="image/jpeg"), "meta": {"frame_index": i}})
185
+ except Exception:
186
+ continue
187
+ content = [{"type": "text", "text": prompt + "\n\nPlease consolidate observations across these frames into a single cohesive narrative."}] + image_entries
188
+ messages = [{"role": "system", "content": SYSTEM_INSTRUCTION}, {"role": "user", "content": content}]
189
+ return chat_complete(client, PIXTRAL_MODEL, messages)
190
+
191
+ def determine_media_type(src: str) -> Tuple[bool, bool]:
192
+ is_image = False; is_video = False
193
+ ext = ext_from_src(src)
194
+ if ext in IMAGE_EXTS: is_image = True
195
+ if ext in VIDEO_EXTS: is_video = True
196
+ if is_remote(src):
197
+ head = safe_head(src)
198
+ if head:
199
+ ctype = (head.headers.get("content-type") or "").lower()
200
+ if ctype.startswith("image/"): is_image, is_video = True, False
201
+ elif ctype.startswith("video/"): is_video, is_image = True, False
202
+ return is_image, is_video
203
 
204
+ def process_media(src: str, custom_prompt: str, api_key: str, progress=gr.Progress()) -> str:
205
+ client = get_client(api_key)
206
+ prompt = (custom_prompt or "").strip() or "Please provide a detailed visual review."
207
+ if not src: return "No URL or path provided."
208
+ progress(0.05, desc="Determining media type")
209
+ is_image, is_video = determine_media_type(src)
210
+ if is_image:
211
+ try:
212
+ raw = fetch_bytes(src)
213
+ except Exception as e:
214
+ return f"Error fetching image: {e}"
215
+ progress(0.2, desc="Analyzing image")
216
+ try:
217
+ return analyze_image_structured(client, raw, prompt)
218
+ except UnidentifiedImageError:
219
+ return "Error: provided file is not a valid image."
220
+ except Exception as e:
221
+ return f"Error analyzing image: {e}"
222
+ if is_video:
223
+ try:
224
+ raw = fetch_bytes(src, timeout=120)
225
+ except Exception as e:
226
+ return f"Error fetching video: {e}"
227
+ tmp_path = save_bytes_to_temp(raw, suffix=ext_from_src(src) or ".mp4")
228
+ try:
229
+ progress(0.2, desc="Analyzing video")
230
+ return analyze_video_cohesive(client, tmp_path, prompt)
231
+ finally:
232
+ try: os.remove(tmp_path)
233
+ except Exception: pass
234
+ try:
235
+ raw = fetch_bytes(src)
236
+ progress(0.2, desc="Treating as image")
237
+ return analyze_image_structured(client, raw, prompt)
238
+ except Exception as e:
239
+ return f"Unable to determine media type or fetch file: {e}"
240
+
241
+ # --- Gradio UI
242
+ css = ".preview_media img, .preview_media video { max-width: 100%; height: auto; border-radius:6px; }"
243
+
244
+ def _btn_label_for_status(status: str) -> str:
245
+ return {"idle": "Submit", "busy": "Processing…", "done": "Submit", "error": "Retry"}.get(status or "idle", "Submit")
246
 
247
  def create_demo():
248
+ with gr.Blocks(title="Flux Multimodal", css=css) as demo:
249
  with gr.Row():
250
  with gr.Column(scale=1):
251
+ preview_image = gr.Image(label="Preview Image", type="pil", elem_classes="preview_media", visible=False)
252
+ preview_video = gr.Video(label="Preview Video", elem_classes="preview_media", visible=False)
253
+ pip_button = gr.Button("Open Video in PiP", visible=False)
254
+ with gr.Column(scale=2):
255
  url_input = gr.Textbox(label="Image / Video URL or local path", placeholder="https://... or /path/to/file", lines=1)
256
  with gr.Accordion("Prompt (optional)", open=False):
257
  custom_prompt = gr.Textbox(label="Prompt", lines=4, value="")
258
  with gr.Accordion("Mistral API Key (optional)", open=False):
259
  api_key = gr.Textbox(label="API Key", type="password", max_lines=1)
260
+ submit_btn = gr.Button(_btn_label_for_status("idle"))
261
  clear_btn = gr.Button("Clear")
262
+ output_md = gr.Markdown("")
263
+ status_state = gr.State("idle")
264
+
265
+ pip_html = gr.HTML("""<div id="pip-root" style="display:none"></div>
266
+ <script>
267
+ window.openPiP = (sel) => {
268
+ try {
269
+ const v = document.querySelector(sel);
270
+ if (!v) return "no-video";
271
+ if (v.requestPictureInPicture) { v.requestPictureInPicture(); return "opened"; }
272
+ return "unsupported";
273
+ } catch(e){ return "error:"+e; }
274
+ };
275
+ </script>""")
276
 
277
+ def load_preview(url: str):
278
+ empty_img = gr.update(value=None, visible=False)
279
+ empty_vid = gr.update(value=None, visible=False)
280
+ pip_vis = gr.update(visible=False)
281
+ if not url: return empty_img, empty_vid, pip_vis
282
+ if not is_remote(url) and os.path.exists(url):
283
+ ext = ext_from_src(url)
284
+ if ext in VIDEO_EXTS: return empty_img, gr.update(value=os.path.abspath(url), visible=True), gr.update(visible=True)
285
+ if ext in IMAGE_EXTS:
286
+ try:
287
+ img = Image.open(url)
288
+ if getattr(img, "is_animated", False): img.seek(0)
289
+ return gr.update(value=img.convert("RGB"), visible=True), empty_vid, pip_vis
290
+ except Exception: return empty_img, empty_vid, pip_vis
291
+ head = safe_head(url)
292
+ if head:
293
+ ctype = (head.headers.get("content-type") or "").lower()
294
+ if ctype.startswith("video/") or any(url.lower().endswith(ext) for ext in VIDEO_EXTS):
295
+ return empty_img, gr.update(value=url, visible=True), gr.update(visible=True)
296
+ try:
297
+ r = safe_get(url, timeout=15)
298
+ img = Image.open(BytesIO(r.content))
299
+ if getattr(img, "is_animated", False): img.seek(0)
300
+ return gr.update(value=img.convert("RGB"), visible=True), empty_vid, pip_vis
301
+ except Exception:
302
+ return empty_img, empty_vid, pip_vis
303
 
304
  url_input.change(fn=load_preview, inputs=[url_input], outputs=[preview_image, preview_video, pip_button])
305
 
306
+ def clear_all():
307
+ return "", gr.update(value=None, visible=False), gr.update(value=None, visible=False), "idle", gr.update(value=_btn_label_for_status("idle"))
308
+ clear_btn.click(fn=clear_all, inputs=[], outputs=[url_input, preview_image, preview_video, status_state, submit_btn])
309
+
310
+ def pip_click(_):
311
+ js = "<script>setTimeout(()=>window.openPiP('video.preview_media'),50);</script>"
312
+ return gr.HTML.update(value=js)
313
+ pip_button.click(fn=pip_click, inputs=[url_input], outputs=[pip_html])
314
 
315
  def start_busy():
316
+ s = "busy"
317
+ return s, gr.update(value=_btn_label_for_status(s))
318
+ submit_btn.click(fn=start_busy, inputs=[], outputs=[status_state, submit_btn])
319
 
320
  def worker(url: str, prompt: str, key: str, progress=gr.Progress()):
321
  return process_media(url or "", prompt or "", key or "", progress=progress)
322
+ submit_btn.click(fn=worker, inputs=[url_input, custom_prompt, api_key], outputs=[output_md], queue=True).then(
323
+ fn=lambda res: ("error", "**Error:** no result returned.") if not res else
324
+ ("error", f"**Error:** {res}") if isinstance(res, str) and res.lower().startswith("error") else ("done", res),
325
+ inputs=[output_md],
326
+ outputs=[status_state, output_md],
 
 
 
 
 
 
 
 
 
 
 
 
327
  )
328
 
329
  def btn_label_for_state(s: str):
330
+ return _btn_label_for_status(s)
331
+ status_state.change(fn=btn_label_for_state, inputs=[status_state], outputs=[submit_btn])
 
 
 
 
 
 
 
 
 
 
332
 
333
  return demo
334
 
 
335
  if __name__ == "__main__":
336
+ create_demo().launch()