srijika-demo / app.py
anilpai's picture
Update donor-search copy to fc-v7 (48 attrs, measured contrast)
c2e78d9 verified
Raw
History Blame Contribute Delete
14.2 kB
"""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 &rarr; a diffusion model draws it,
glyph by glyph, for Devanagari, Tamil, Bengali, Telugu, Kannada,
Malayalam, Gujarati, Gurmukhi &amp; Odia.</p>
<span class="badge">text &rarr; 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 &middot; ~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()