Spaces:
Running
Running
| """Srijika — text-to-font studio for Indic scripts (public demo). | |
| Thin client for the private Srijika API. No model code or weights here: | |
| every render arrives as a PNG from the server. The demo key lives in a | |
| Space Secret (SRIJIKA_DEMO_KEY) and is never exposed to visitors. | |
| """ | |
| import io | |
| import os | |
| import time | |
| import gradio as gr | |
| import requests | |
| API = os.environ.get("SRIJIKA_API_URL", | |
| "https://loopdesk-ai--srijika-api-api.modal.run") | |
| KEY = os.environ.get("SRIJIKA_DEMO_KEY", "") | |
| HDRS = {"Authorization": f"Bearer {KEY}"} | |
| SCRIPTS = ["devanagari", "tamil", "bengali", "telugu", "kannada", | |
| "malayalam", "gujarati", "gurmukhi", "odia"] | |
| EXAMPLES = [ | |
| ["a heavy rounded poster font", "devanagari"], | |
| ["thin elegant headline serif", "devanagari"], | |
| ["playful comic lettering for kids", "bengali"], | |
| ["clean geometric UI font", "tamil"], | |
| ["warm rounded friendly font", "kannada"], | |
| ["sharp modern tech branding", "telugu"], | |
| ["traditional elegant bookish serif", "malayalam"], | |
| ["bold cinematic display font", "gujarati"], | |
| ["clean geometric sans", "gurmukhi"], | |
| ["warm rounded friendly font", "odia"], | |
| ["brush calligraphy with dramatic strokes", "devanagari"], | |
| ] | |
| CSS = """ | |
| .gradio-container {max-width: 1080px !important; margin: 0 auto;} | |
| #hero {text-align:center; padding: 26px 10px 6px;} | |
| #hero h1 {font-size: 2.5em; margin: 0; | |
| background: linear-gradient(90deg,#f59e0b,#ef4444,#a855f7); | |
| -webkit-background-clip: text; -webkit-text-fill-color: transparent;} | |
| #hero p {color:#6b7280; margin-top:6px; font-size:1.05em;} | |
| .badge {display:inline-block; background:#111827; color:#e5e7eb; | |
| border-radius:999px; padding:3px 12px; margin:2px; font-size:.8em;} | |
| #donorcard {border:1px solid #e5e7eb; border-radius:14px; padding:14px; | |
| background:linear-gradient(180deg,#fffbeb,#ffffff);} | |
| #status {font-size:.95em;} | |
| footer {display:none !important;} | |
| """ | |
| HERO = """ | |
| <div id="hero"> | |
| <h1>Srijika · सृजिका</h1> | |
| <p>Describe a font in plain words → a diffusion model draws it, | |
| glyph by glyph, for Devanagari, Tamil, Bengali, Telugu, Kannada, | |
| Malayalam, Gujarati, Gurmukhi & Odia.</p> | |
| <span class="badge">text → font</span> | |
| <span class="badge">glyph diffusion</span> | |
| <span class="badge">FontCLIP retrieval</span> | |
| <span class="badge">parametric axes</span> | |
| <span class="badge">draft = 30 glyphs · ~2 min</span> | |
| </div> | |
| """ | |
| ABOUT = """ | |
| **How it works** — Your description is embedded by | |
| [Lipika-FontCLIP](https://huggingface.co/loopdesk-ai/lipika-fontclip) and | |
| matched against 1,700+ Indic font faces. The best match seeds a private | |
| glyph-diffusion model (Srijika) that redraws the alphabet in that style on | |
| a GPU. Drafts render 30 glyphs so you can iterate quickly; the full model | |
| draws complete Unicode coverage with conjuncts. | |
| **Parametric axes** — after generation, weight / counter / em-fill are | |
| geometric transforms applied server-side, so one generation yields a family. | |
| *Public demo: draft quality, rate-limited, shared queue. Fonts are derived | |
| from OFL-licensed donors.* | |
| """ | |
| def _get(path, **params): | |
| r = requests.get(f"{API}{path}", headers=HDRS, params=params, timeout=120) | |
| r.raise_for_status() | |
| return r | |
| def _png(resp): | |
| return resp.content if resp.headers.get( | |
| "content-type", "").startswith("image/") else None | |
| def api_ok(): | |
| try: | |
| return requests.get(f"{API}/v1/health", timeout=10).ok and bool(KEY) | |
| except Exception: | |
| return False | |
| # ---------------------------------------------------------------- search | |
| def do_search(q, script): | |
| if not q.strip(): | |
| return "Type a description first.", gr.update(visible=False) | |
| try: | |
| d = _get("/v1/search", q=q, script=script or "", k=6).json() | |
| except requests.HTTPError as e: | |
| return f"Search failed: {e.response.text[:200]}", gr.update(visible=False) | |
| rows, gallery = [], [] | |
| for r in d["results"]: | |
| rows.append(f"**{r['family']}** · score {r['score']:.3f}") | |
| if r.get("file"): | |
| try: | |
| png = _png(_get("/v1/donor-preview", | |
| file=r["file"], script=script or "devanagari")) | |
| if png: | |
| gallery.append((io.BytesIO(png).getvalue(), r["family"])) | |
| except Exception: | |
| pass | |
| import tempfile | |
| paths = [] | |
| for data, label in gallery: | |
| f = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| f.write(data) | |
| f.close() | |
| paths.append((f.name, label)) | |
| md = (f"**{d.get('n_candidates', len(d['results']))} candidate faces " | |
| f"searched** — top matches:\n\n" + "\n\n".join(rows)) | |
| return md, gr.update(value=paths, visible=bool(paths)) | |
| # -------------------------------------------------------------- generate | |
| def do_generate(text, script, progress=gr.Progress()): | |
| empty = gr.update(visible=False) | |
| if not text.strip(): | |
| yield "Type a description first.", None, None, empty, gr.update(visible=False) | |
| return | |
| progress(0.02, desc="Finding the closest real font…") | |
| try: | |
| r = requests.post(f"{API}/v1/generate", headers=HDRS, | |
| data={"text": text, "script": script}, timeout=300) | |
| r.raise_for_status() | |
| d = r.json() | |
| except requests.HTTPError as e: | |
| code = e.response.status_code | |
| msg = ("Rate limit reached — the shared demo allows a few " | |
| "generations per hour. Try again later." | |
| if code == 429 else f"Generate failed: {e.response.text[:300]}") | |
| yield msg, None, None, empty, gr.update(visible=False) | |
| return | |
| job, donor = d["job_id"], d["donor"] | |
| donor_md = (f"### 🎯 Donor matched: **{donor['family']}**\n" | |
| f"score {donor['score']:.3f} · " | |
| f"{d['n_candidates']} faces searched · " | |
| f"template `{d['params']['template']}`") | |
| yield (f"⏳ Drawing glyphs on GPU (draft, ~2 min)… job `{job}`", | |
| None, None, gr.update(value=donor_md, visible=True), | |
| gr.update(visible=False)) | |
| t0 = time.time() | |
| while time.time() - t0 < 420: | |
| progress(min(.05 + (time.time() - t0) / 140 * .9, .95), | |
| desc="Diffusing glyphs…") | |
| time.sleep(6) | |
| try: | |
| s = _get(f"/v1/jobs/{job}").json() | |
| except Exception: | |
| continue | |
| if s.get("status") == "done": | |
| c = s["critic"] | |
| cl = c.get("cluster") or {} | |
| n_g = s.get("stats", {}).get("replaced", "?") | |
| png = _png(_get(f"/v1/jobs/{job}/preview")) | |
| ttf = _get(f"/v1/jobs/{job}/font").content | |
| import tempfile | |
| pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| pf.write(png or b"") | |
| pf.close() | |
| tf = tempfile.NamedTemporaryFile( | |
| suffix=".ttf", delete=False, | |
| prefix=s["params"]["family"].replace(" ", "") + "-") | |
| tf.write(ttf) | |
| tf.close() | |
| acc = cl.get("acc") | |
| stat = (f"✅ **Done in {time.time()-t0:.0f}s** · " | |
| f"{n_g} glyphs drawn · " | |
| f"legible {c['legible_frac']:.2f}" | |
| + (f" · cluster acc {acc:.2f}" if acc is not None else "") | |
| + f"\n\njob `{job}`") | |
| yield (stat, pf.name, tf.name, | |
| gr.update(value=donor_md, visible=True), | |
| gr.update(visible=True)) | |
| return | |
| if s.get("status") == "failed": | |
| yield (f"❌ Job failed: {s.get('error','?')[:300]}", | |
| None, None, empty, gr.update(visible=False)) | |
| return | |
| yield ("⌛ Still running — press *Check again* in a minute.", | |
| None, None, gr.update(value=donor_md, visible=True), | |
| gr.update(visible=False)) | |
| def apply_axes(state_job, weight, counter, emfill): | |
| if not state_job: | |
| return None, None, "Generate a font first." | |
| try: | |
| png = _png(_get(f"/v1/jobs/{state_job}/preview", | |
| weight=weight, counter=counter, emfill=emfill)) | |
| ttf = _get(f"/v1/jobs/{state_job}/font", | |
| weight=weight, counter=counter, emfill=emfill) | |
| except requests.HTTPError as e: | |
| return None, None, f"Axis render failed: {e.response.text[:200]}" | |
| import tempfile | |
| pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| pf.write(png or b"") | |
| pf.close() | |
| disp = ttf.headers.get("content-disposition", "") | |
| name = disp.split("filename=")[-1].strip('"') or "srijika.ttf" | |
| tf = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False, | |
| prefix=name.rsplit(".", 1)[0] + "-") | |
| tf.write(ttf.content) | |
| tf.close() | |
| rep = ttf.headers.get("x-srijika-axes", "") | |
| return pf.name, tf.name, f"Axes applied · `{rep}`" if rep else "Axes applied." | |
| # --------------------------------------------------------------- presets | |
| def load_presets(): | |
| try: | |
| d = _get("/v1/presets").json() | |
| except Exception as e: | |
| return gr.update(choices=[], value=None), f"Could not load presets: {e}" | |
| ids = [p["id"] for p in d["presets"]] | |
| return (gr.update(choices=ids, value=ids[0] if ids else None), | |
| f"{d['count']} preset styles available.") | |
| def show_preset(pid, weight, counter, emfill): | |
| if not pid: | |
| return None, None, "Pick a preset." | |
| try: | |
| png = _png(_get(f"/v1/presets/{pid}/preview", | |
| weight=weight, counter=counter, emfill=emfill)) | |
| ttf = _get(f"/v1/presets/{pid}/font", | |
| weight=weight, counter=counter, emfill=emfill).content | |
| except requests.HTTPError as e: | |
| return None, None, f"Preset failed: {e.response.text[:200]}" | |
| import tempfile | |
| pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) | |
| pf.write(png or b"") | |
| pf.close() | |
| tf = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False, | |
| prefix=pid + "-") | |
| tf.write(ttf) | |
| tf.close() | |
| return pf.name, tf.name, f"**{pid}** ready." | |
| # ------------------------------------------------------------------- UI | |
| with gr.Blocks(css=CSS, title="Srijika — text to Indic font", | |
| theme=gr.themes.Soft(primary_hue="amber")) as demo: | |
| gr.HTML(HERO) | |
| if not api_ok(): | |
| gr.Markdown("> ⚠️ **Demo backend unreachable or key missing.** " | |
| "The API may be waking up — reload in a minute.") | |
| with gr.Tab("✨ Text → Font"): | |
| with gr.Row(): | |
| txt = gr.Textbox(label="Describe your font", | |
| placeholder="e.g. a heavy rounded poster font " | |
| "with warm friendly curves", | |
| scale=4) | |
| scr = gr.Dropdown(SCRIPTS, value="devanagari", | |
| label="Script", scale=1) | |
| gen_btn = gr.Button("Generate draft font", variant="primary") | |
| gr.Examples(EXAMPLES, inputs=[txt, scr], label="Try one of these") | |
| donor_card = gr.Markdown(visible=False, elem_id="donorcard") | |
| status = gr.Markdown(elem_id="status") | |
| with gr.Row(): | |
| preview = gr.Image(label="Specimen", type="filepath", | |
| interactive=False) | |
| ttf_out = gr.File(label="Download TTF") | |
| with gr.Group(visible=False) as axes_grp: | |
| gr.Markdown("#### 🎛 Parametric axes — restyle without re-generating") | |
| with gr.Row(): | |
| w = gr.Slider(-40, 80, 0, step=5, label="Weight") | |
| c = gr.Slider(0.8, 1.2, 1.0, step=0.05, label="Counter") | |
| e = gr.Slider(0.95, 1.06, 1.0, step=0.01, label="Em-fill") | |
| ax_btn = gr.Button("Apply axes") | |
| job_state = gr.State("") | |
| gen_btn.click( | |
| do_generate, [txt, scr], | |
| [status, preview, ttf_out, donor_card, axes_grp]) | |
| def grab_job(stat_md): | |
| import re | |
| m = re.findall(r"job `([^`]+)`", stat_md or "") | |
| return m[-1] if m else gr.update() | |
| status.change(grab_job, status, job_state) | |
| ax_btn.click(apply_axes, [job_state, w, c, e], | |
| [preview, ttf_out, status]) | |
| with gr.Tab("🎨 Preset gallery"): | |
| gr.Markdown("Pre-generated Srijika families — instant download, " | |
| "same parametric axes.") | |
| with gr.Row(): | |
| pre_dd = gr.Dropdown([], label="Preset", scale=3) | |
| pre_btn = gr.Button("Load presets", scale=1) | |
| pre_msg = gr.Markdown() | |
| with gr.Row(): | |
| pw = gr.Slider(-40, 80, 0, step=5, label="Weight") | |
| pc = gr.Slider(0.8, 1.2, 1.0, step=0.05, label="Counter") | |
| pe = gr.Slider(0.95, 1.06, 1.0, step=0.01, label="Em-fill") | |
| pre_show = gr.Button("Render specimen", variant="primary") | |
| pre_img = gr.Image(label="Specimen", type="filepath", interactive=False) | |
| pre_file = gr.File(label="Download TTF") | |
| pre_btn.click(load_presets, None, [pre_dd, pre_msg]) | |
| pre_show.click(show_preset, [pre_dd, pw, pc, pe], | |
| [pre_img, pre_file, pre_msg]) | |
| with gr.Tab("🔎 Donor search"): | |
| gr.Markdown("Peek at the retrieval layer: which **real** Indic fonts " | |
| "best match a description (Lipika-FontCLIP fc-v7: 48 style " | |
| "attributes incl. measured stroke contrast).") | |
| with gr.Row(): | |
| sq = gr.Textbox(label="Description", | |
| placeholder="thin elegant headline serif", scale=4) | |
| ss = gr.Dropdown([""] + SCRIPTS, value="", label="Script filter", | |
| scale=1) | |
| s_btn = gr.Button("Search corpus", variant="primary") | |
| s_md = gr.Markdown() | |
| s_gal = gr.Gallery(label="Top donor faces", columns=3, height=420, | |
| visible=False) | |
| s_btn.click(do_search, [sq, ss], [s_md, s_gal]) | |
| with gr.Tab("ℹ️ About"): | |
| gr.Markdown(ABOUT) | |
| if __name__ == "__main__": | |
| demo.queue(max_size=20).launch() | |