ChevalierJoseph commited on
Commit
d29dcbc
·
verified ·
1 Parent(s): 481d518

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +187 -56
app.py CHANGED
@@ -4,7 +4,7 @@ import spaces
4
  import gradio as gr
5
  from PIL import Image
6
 
7
- # --- CONSTANTES ---
8
  POTRACE_BIN = 'potrace'
9
  UPM = 1000
10
  CROP = 8
@@ -14,19 +14,19 @@ PRECISION = 1
14
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
15
  DEFAULT_IMG_PROMPT = "Design a custom typeface that takes direct inspiration on the attached control image. The font should faithfully replicate the unique style of the reference."
16
 
17
- # --- MAPPINGS 6x6 ---
18
  MAP_UC = [['A','B','C','D','E','F'],['G','H','I','J','K','L'],['M','N','O','P','Q','R'],['S','T','U','V','W','X'],['Y','Z','Æ','Œ','Ø','ß'],['Ç','.',',',';','?','!']]
19
  MAP_LC = [['a','b','c','d','e','f'],['g','h','i','j','k','l'],['m','n','o','p','q','r'],['s','t','u','v','w','x'],['y','z','æ','œ','ø','Ð'],['ç',':',"'",'"','«','<']]
20
  MAP_PUNC = [['1','2','3','4','5','6'],['7','8','9','0','#','%'],['(','[','{','$','€','£'],['&','@','_','+','-','='],['*','/','^','°','→','—'],['`','´','ˆ','¨','˜','•']]
21
 
22
- # --- RÉFÉRENCES DE BASELINE PAR GRILLE ---
23
  BASELINE_REFS = {
24
  0: ['H', 'I', 'E', 'A', 'B', 'D', 'F', 'L', 'M', 'N', 'P', 'R', 'T', 'U', 'V', 'X', 'Y', 'Z'],
25
  1: ['n', 'm', 'u', 'x', 'h', 'i', 'l', 'k', 'r', 'v', 'w', 'z', 'a', 'e', 'o'],
26
  2: ['1', '0', '2', '4', '7', '8', '9', '#', '%', '$', '£', '&', '@', '+', '='],
27
  }
28
 
29
- # --- TRAITEMENT SVG ---
30
  def simplify_svg_path(d):
31
  from fontTools.pens.recordingPen import RecordingPen
32
  from fontTools.pens.svgPathPen import SVGPathPen
@@ -68,7 +68,7 @@ def get_char_path_rdp(pil_img, upscale=2):
68
  paths = re.findall(r'd="([^"]+)"', content)
69
  return ' '.join(simplify_svg_path(p) for p in paths).strip() if paths else ""
70
 
71
- # --- CONSTRUCTION OTF ---
72
  MIRROR_MAP = {')': '(', ']': '[', '}': '{'}
73
 
74
  ACCENT_MAP = {
@@ -125,7 +125,7 @@ def build_otf(images, font_name):
125
  if bp.bounds:
126
  glyph_data[char] = {'d': d, 'b': bp.bounds, 'scale': scale, 'grid': idx}
127
 
128
- # Baseline calculée
129
  baseline_per_grid = {}
130
  for grid_idx in range(len(all_maps)):
131
  chosen_ref = None
@@ -137,9 +137,9 @@ def build_otf(images, font_name):
137
  break
138
  if chosen_ref is None:
139
  baseline_per_grid[grid_idx] = baseline_per_grid.get(0, 0)
140
- print(f"⚠️ Grille {grid_idx} : aucun caractère de référence trouvé, fallback = {baseline_per_grid[grid_idx]:.1f}")
141
  else:
142
- print(f"📐 Grille {grid_idx} : baseline = {baseline_per_grid[grid_idx]:.1f} (réf '{chosen_ref}')")
143
 
144
  def get_baseline(char):
145
  if char in glyph_data:
@@ -190,7 +190,7 @@ def build_otf(images, font_name):
190
  cmap[ord(char)] = glyph_name
191
  metrics[glyph_name] = (width, 0)
192
 
193
- # Miroirs
194
  for dst_char, src_char in MIRROR_MAP.items():
195
  if src_char not in font_recordings:
196
  continue
@@ -209,7 +209,7 @@ def build_otf(images, font_name):
209
  cx, cy = font_centers[src_char]
210
  font_centers[dst_char] = (w - cx, cy)
211
 
212
- # Accentués
213
  for dst_char, (base_char, accent_char) in ACCENT_MAP.items():
214
  if base_char not in font_recordings or accent_char not in font_recordings:
215
  continue
@@ -235,7 +235,7 @@ def build_otf(images, font_name):
235
  ax, ay = font_centers[accent_char]
236
  font_centers[dst_char] = (bx + dx, by + dy)
237
 
238
- # Miroirs bracket
239
  MIRROR_PAIRS = {'(': ')', '[': ']', '{': '}'}
240
  for src_char, dst_char in MIRROR_PAIRS.items():
241
  if src_char not in glyph_data:
@@ -258,7 +258,7 @@ def build_otf(images, font_name):
258
  cx, cy = font_centers[src_char]
259
  font_centers[dst_char] = (width - cx, cy)
260
 
261
- # Alias accents combinants
262
  ACCENT_ALIASES = {
263
  '`': [0x0060, 0x0300], '´': [0x00B4, 0x0301],
264
  'ˆ': [0x02C6, 0x0302], '¨': [0x00A8, 0x0308], '˜': [0x02DC, 0x0303],
@@ -305,7 +305,7 @@ def build_otf(images, font_name):
305
  fb.setupPost(italicAngle=0, underlinePosition=REF["ulP"], underlineThickness=REF["ulT"])
306
 
307
  # -------------------------------------------------------------------------
308
- # BUBBLE KERNING — vectoriel pur, écrit en GPOS (pas de limite de paires)
309
  # -------------------------------------------------------------------------
310
  try:
311
  import numpy as np
@@ -313,11 +313,11 @@ def build_otf(images, font_name):
313
  from fontTools.ttLib import newTable
314
  from fontTools.ttLib.tables import otTables
315
 
316
- # --- Paramètres ---
317
- BUBBLE_RADIUS = 10 # rayon de la bulle en UPM, doit être < SIDEBEARING (20)
318
- N_SAMPLES = 300 # points échantillonnés par contour
319
- KERN_THRESHOLD = -2 # paires dont kern seuil ignorées (bruit sub-UPM)
320
- KERN_CAP = -280 # plancher anti-collision
321
 
322
  def sample_contour(recording, n=N_SAMPLES):
323
  pts = []
@@ -362,7 +362,7 @@ def build_otf(images, font_name):
362
  arr = arr[idx]
363
  return arr
364
 
365
- # Échantillonnage
366
  chars_available = [c for c in font_recordings if len(c) == 1]
367
  contour_points = {}
368
  for char in chars_available:
@@ -370,11 +370,11 @@ def build_otf(images, font_name):
370
  if pts is not None and len(pts) >= 2:
371
  contour_points[char] = pts
372
 
373
- print(f"📐 Contours échantillonnés : {len(contour_points)} glyphes")
374
 
375
- # Calcul all-to-all
376
  diameter = BUBBLE_RADIUS * 2.0
377
- gpos_pairs = {} # (glyph_name_L, glyph_name_R) kern_upm
378
 
379
  for l_char in contour_points:
380
  pts_L = contour_points[l_char]
@@ -394,12 +394,12 @@ def build_otf(images, font_name):
394
  kern_upm = int(round(diameter - min_dist))
395
 
396
  if l_char in ('A','V','W') and r_char in ('A','V','W'):
397
- print(f" {l_char}{r_char} : min_dist={min_dist:.1f} kern={kern_upm}")
398
 
399
  if kern_upm < KERN_THRESHOLD:
400
  gpos_pairs[(lg, rg)] = max(kern_upm, KERN_CAP)
401
 
402
- print(f"✅ Bubble Kerning vectoriel : {len(gpos_pairs)} paires")
403
 
404
  if gpos_pairs:
405
  val0 = buildValue({})
@@ -460,14 +460,14 @@ def build_otf(images, font_name):
460
  gpos = newTable("GPOS")
461
  gpos.table = gpos_table
462
  fb.font["GPOS"] = gpos
463
- print(f"✅ GPOS écrit : {len(gpos_pairs)} paires kern")
464
 
465
  except Exception as e:
466
  print(f"⚠️ Bubble Kerning failed: {e}")
467
  import traceback
468
  traceback.print_exc()
469
 
470
- # --- Sauvegarde finale en OTF ---
471
  tmp_otf = f"/tmp/{font_name}.otf"
472
  fb.save(tmp_otf)
473
  with open(tmp_otf, "rb") as f:
@@ -475,15 +475,15 @@ def build_otf(images, font_name):
475
  os.remove(tmp_otf)
476
  return data
477
 
478
- # --- MÉTRIQUES DE RÉFÉRENCE (Helvetica LT Std Regular) ---
479
  REF = {
480
  "asc": 718, "dsc": -282, "tAsc": 718, "tDsc": -282, "tGap": 200,
481
  "wAsc": 931, "wDsc": 225, "xH": 524, "cH": 718, "ulP": -75, "ulT": 50
482
  }
483
 
484
  # =============================================================================
485
- # CHARGEMENT DU MODÈLE (au scope module, une seule fois au démarrage du Space)
486
- # ZeroGPU : .to("cuda") ici est OK, l'init CUDA réelle est différée par `spaces`.
487
  # =============================================================================
488
  import torch
489
  from diffusers import Flux2KleinPipeline
@@ -501,12 +501,12 @@ pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaMA
501
  pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaMIN.safetensors", adapter_name="lc")
502
  pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaPONCT.safetensors", adapter_name="punc")
503
  pipe.to("cuda")
504
- print("✅ Modèle prêt !")
505
 
506
 
507
  # =============================================================================
508
- # INFÉRENCE GPU — uniquement les 3 passes FLUX sous @spaces.GPU
509
- # duration = temps max d'allocation GPU (compte sur le quota ZeroGPU).
510
  # =============================================================================
511
  @spaces.GPU(duration=180)
512
  def run_pipeline(enhanced, input_img, seed):
@@ -546,7 +546,7 @@ def run_pipeline(enhanced, input_img, seed):
546
 
547
 
548
  # =============================================================================
549
- # ORCHESTRATEUR + UI GRADIO
550
  # =============================================================================
551
  def _render_preview(otf_path, font_name):
552
  try:
@@ -558,7 +558,7 @@ def _render_preview(otf_path, font_name):
558
  draw.multiline_text((40, 40), sample, font=font, fill="black", spacing=24)
559
  return img
560
  except Exception as e:
561
- print(f"⚠️ Aperçu impossible: {e}")
562
  return None
563
 
564
 
@@ -577,16 +577,16 @@ def generate(prompt, control_image, seed, random_seed, progress=gr.Progress(trac
577
  enhanced = DEFAULT_IMG_PROMPT
578
  else:
579
  if not (prompt and prompt.strip()):
580
- raise gr.Error("Donne un prompt OU une image de contrôle.")
581
- enhanced = prompt # input utilisateur brut, aucun raffinement
582
 
583
- # --- GPU : 3 passes FLUX ---
584
  img1, img2, img3 = run_pipeline(enhanced, input_img, seed)
585
 
586
  if not font_name:
587
  font_name = "Font" + "".join(random.choices(string.ascii_uppercase, k=3))
588
 
589
- # --- CPU : vectorisation potrace + build OTF ---
590
  otf_bytes = build_otf([img1, img2, img3], font_name)
591
 
592
  out_dir = tempfile.mkdtemp()
@@ -594,36 +594,167 @@ def generate(prompt, control_image, seed, random_seed, progress=gr.Progress(trac
594
  with open(otf_path, "wb") as f:
595
  f.write(otf_bytes)
596
 
597
- gallery = [(img1, "Majuscules"), (img2, "Minuscules"), (img3, "Ponctuation")]
598
  preview = _render_preview(otf_path, font_name)
599
- status = f" **{font_name}** seed `{seed}`"
600
  return gallery, preview, otf_path, status
601
 
602
 
603
- with gr.Blocks(title="Typotopia", theme=gr.themes.Soft()) as demo:
604
- gr.Markdown(
605
- "# 🔤 Typotopia\n"
606
- "Génère une police OpenType complète (.otf) depuis un **prompt** ou une "
607
- "**image de contrôle**. FLUX.2-klein + LoRAs (ZeroGPU) → vectorisation potrace "
608
- "→ fontTools avec kerning vectoriel."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  )
610
- with gr.Row():
611
  with gr.Column(scale=1):
 
612
  prompt = gr.Textbox(
613
  label="Prompt",
614
- placeholder="ex. a bold geometric sans inspired by brutalist concrete signage",
615
  lines=3,
616
  )
617
- control_image = gr.Image(label="Image de contrôle (optionnelle)", type="pil")
618
  with gr.Row():
619
  seed = gr.Number(label="Seed", value=0, precision=0)
620
- random_seed = gr.Checkbox(label="Seed aléatoire", value=True)
621
- btn = gr.Button("Générer la police", variant="primary")
622
  with gr.Column(scale=1):
623
- status = gr.Markdown()
624
- preview = gr.Image(label="Aperçu", type="pil")
625
- gallery = gr.Gallery(label="Grilles générées", columns=3, height="auto")
626
- otf_file = gr.File(label="Télécharger le .otf")
 
627
 
628
  btn.click(
629
  generate,
@@ -632,4 +763,4 @@ with gr.Blocks(title="Typotopia", theme=gr.themes.Soft()) as demo:
632
  )
633
 
634
  if __name__ == "__main__":
635
- demo.queue().launch()
 
4
  import gradio as gr
5
  from PIL import Image
6
 
7
+ # --- CONSTANTS ---
8
  POTRACE_BIN = 'potrace'
9
  UPM = 1000
10
  CROP = 8
 
14
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
15
  DEFAULT_IMG_PROMPT = "Design a custom typeface that takes direct inspiration on the attached control image. The font should faithfully replicate the unique style of the reference."
16
 
17
+ # --- 6x6 MAPPINGS ---
18
  MAP_UC = [['A','B','C','D','E','F'],['G','H','I','J','K','L'],['M','N','O','P','Q','R'],['S','T','U','V','W','X'],['Y','Z','Æ','Œ','Ø','ß'],['Ç','.',',',';','?','!']]
19
  MAP_LC = [['a','b','c','d','e','f'],['g','h','i','j','k','l'],['m','n','o','p','q','r'],['s','t','u','v','w','x'],['y','z','æ','œ','ø','Ð'],['ç',':',"'",'"','«','<']]
20
  MAP_PUNC = [['1','2','3','4','5','6'],['7','8','9','0','#','%'],['(','[','{','$','€','£'],['&','@','_','+','-','='],['*','/','^','°','→','—'],['`','´','ˆ','¨','˜','•']]
21
 
22
+ # --- PER-GRID BASELINE REFERENCES ---
23
  BASELINE_REFS = {
24
  0: ['H', 'I', 'E', 'A', 'B', 'D', 'F', 'L', 'M', 'N', 'P', 'R', 'T', 'U', 'V', 'X', 'Y', 'Z'],
25
  1: ['n', 'm', 'u', 'x', 'h', 'i', 'l', 'k', 'r', 'v', 'w', 'z', 'a', 'e', 'o'],
26
  2: ['1', '0', '2', '4', '7', '8', '9', '#', '%', '$', '£', '&', '@', '+', '='],
27
  }
28
 
29
+ # --- SVG PROCESSING ---
30
  def simplify_svg_path(d):
31
  from fontTools.pens.recordingPen import RecordingPen
32
  from fontTools.pens.svgPathPen import SVGPathPen
 
68
  paths = re.findall(r'd="([^"]+)"', content)
69
  return ' '.join(simplify_svg_path(p) for p in paths).strip() if paths else ""
70
 
71
+ # --- OTF CONSTRUCTION ---
72
  MIRROR_MAP = {')': '(', ']': '[', '}': '{'}
73
 
74
  ACCENT_MAP = {
 
125
  if bp.bounds:
126
  glyph_data[char] = {'d': d, 'b': bp.bounds, 'scale': scale, 'grid': idx}
127
 
128
+ # Computed baseline
129
  baseline_per_grid = {}
130
  for grid_idx in range(len(all_maps)):
131
  chosen_ref = None
 
137
  break
138
  if chosen_ref is None:
139
  baseline_per_grid[grid_idx] = baseline_per_grid.get(0, 0)
140
+ print(f"⚠️ Grid {grid_idx}: no reference glyph found, fallback = {baseline_per_grid[grid_idx]:.1f}")
141
  else:
142
+ print(f"📐 Grid {grid_idx}: baseline = {baseline_per_grid[grid_idx]:.1f} (ref '{chosen_ref}')")
143
 
144
  def get_baseline(char):
145
  if char in glyph_data:
 
190
  cmap[ord(char)] = glyph_name
191
  metrics[glyph_name] = (width, 0)
192
 
193
+ # Mirrors
194
  for dst_char, src_char in MIRROR_MAP.items():
195
  if src_char not in font_recordings:
196
  continue
 
209
  cx, cy = font_centers[src_char]
210
  font_centers[dst_char] = (w - cx, cy)
211
 
212
+ # Accented
213
  for dst_char, (base_char, accent_char) in ACCENT_MAP.items():
214
  if base_char not in font_recordings or accent_char not in font_recordings:
215
  continue
 
235
  ax, ay = font_centers[accent_char]
236
  font_centers[dst_char] = (bx + dx, by + dy)
237
 
238
+ # Bracket mirrors
239
  MIRROR_PAIRS = {'(': ')', '[': ']', '{': '}'}
240
  for src_char, dst_char in MIRROR_PAIRS.items():
241
  if src_char not in glyph_data:
 
258
  cx, cy = font_centers[src_char]
259
  font_centers[dst_char] = (width - cx, cy)
260
 
261
+ # Combining-accent aliases
262
  ACCENT_ALIASES = {
263
  '`': [0x0060, 0x0300], '´': [0x00B4, 0x0301],
264
  'ˆ': [0x02C6, 0x0302], '¨': [0x00A8, 0x0308], '˜': [0x02DC, 0x0303],
 
305
  fb.setupPost(italicAngle=0, underlinePosition=REF["ulP"], underlineThickness=REF["ulT"])
306
 
307
  # -------------------------------------------------------------------------
308
+ # BUBBLE KERNING — pure vector, written to GPOS (no pair-count limit)
309
  # -------------------------------------------------------------------------
310
  try:
311
  import numpy as np
 
313
  from fontTools.ttLib import newTable
314
  from fontTools.ttLib.tables import otTables
315
 
316
+ # --- Parameters ---
317
+ BUBBLE_RADIUS = 10 # bubble radius in UPM, must be < SIDEBEARING (20)
318
+ N_SAMPLES = 300 # sampled points per contour
319
+ KERN_THRESHOLD = -2 # pairs with kern >= threshold are skipped (sub-UPM noise)
320
+ KERN_CAP = -280 # anti-collision floor
321
 
322
  def sample_contour(recording, n=N_SAMPLES):
323
  pts = []
 
362
  arr = arr[idx]
363
  return arr
364
 
365
+ # Sampling
366
  chars_available = [c for c in font_recordings if len(c) == 1]
367
  contour_points = {}
368
  for char in chars_available:
 
370
  if pts is not None and len(pts) >= 2:
371
  contour_points[char] = pts
372
 
373
+ print(f"📐 Contours sampled: {len(contour_points)} glyphs")
374
 
375
+ # All-to-all computation
376
  diameter = BUBBLE_RADIUS * 2.0
377
+ gpos_pairs = {} # (glyph_name_L, glyph_name_R) -> kern_upm
378
 
379
  for l_char in contour_points:
380
  pts_L = contour_points[l_char]
 
394
  kern_upm = int(round(diameter - min_dist))
395
 
396
  if l_char in ('A','V','W') and r_char in ('A','V','W'):
397
+ print(f" {l_char}->{r_char} : min_dist={min_dist:.1f} kern={kern_upm}")
398
 
399
  if kern_upm < KERN_THRESHOLD:
400
  gpos_pairs[(lg, rg)] = max(kern_upm, KERN_CAP)
401
 
402
+ print(f"✅ Bubble Kerning (vector): {len(gpos_pairs)} pairs")
403
 
404
  if gpos_pairs:
405
  val0 = buildValue({})
 
460
  gpos = newTable("GPOS")
461
  gpos.table = gpos_table
462
  fb.font["GPOS"] = gpos
463
+ print(f"✅ GPOS written: {len(gpos_pairs)} kern pairs")
464
 
465
  except Exception as e:
466
  print(f"⚠️ Bubble Kerning failed: {e}")
467
  import traceback
468
  traceback.print_exc()
469
 
470
+ # --- Final OTF save ---
471
  tmp_otf = f"/tmp/{font_name}.otf"
472
  fb.save(tmp_otf)
473
  with open(tmp_otf, "rb") as f:
 
475
  os.remove(tmp_otf)
476
  return data
477
 
478
+ # --- REFERENCE METRICS (Helvetica LT Std Regular) ---
479
  REF = {
480
  "asc": 718, "dsc": -282, "tAsc": 718, "tDsc": -282, "tGap": 200,
481
  "wAsc": 931, "wDsc": 225, "xH": 524, "cH": 718, "ulP": -75, "ulT": 50
482
  }
483
 
484
  # =============================================================================
485
+ # MODEL LOADING (module scope, once at Space startup)
486
+ # ZeroGPU: .to("cuda") here is fine, the real CUDA init is deferred by `spaces`.
487
  # =============================================================================
488
  import torch
489
  from diffusers import Flux2KleinPipeline
 
501
  pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaMIN.safetensors", adapter_name="lc")
502
  pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaPONCT.safetensors", adapter_name="punc")
503
  pipe.to("cuda")
504
+ print("✅ Model ready!")
505
 
506
 
507
  # =============================================================================
508
+ # GPU INFERENCE only the 3 FLUX passes run under @spaces.GPU
509
+ # duration = max GPU allocation per request (counts against the ZeroGPU quota).
510
  # =============================================================================
511
  @spaces.GPU(duration=180)
512
  def run_pipeline(enhanced, input_img, seed):
 
546
 
547
 
548
  # =============================================================================
549
+ # ORCHESTRATOR + GRADIO UI
550
  # =============================================================================
551
  def _render_preview(otf_path, font_name):
552
  try:
 
558
  draw.multiline_text((40, 40), sample, font=font, fill="black", spacing=24)
559
  return img
560
  except Exception as e:
561
+ print(f"⚠️ Preview failed: {e}")
562
  return None
563
 
564
 
 
577
  enhanced = DEFAULT_IMG_PROMPT
578
  else:
579
  if not (prompt and prompt.strip()):
580
+ raise gr.Error("Provide a prompt OR a control image.")
581
+ enhanced = prompt # raw user input, no refinement
582
 
583
+ # --- GPU: 3 FLUX passes ---
584
  img1, img2, img3 = run_pipeline(enhanced, input_img, seed)
585
 
586
  if not font_name:
587
  font_name = "Font" + "".join(random.choices(string.ascii_uppercase, k=3))
588
 
589
+ # --- CPU: potrace vectorization + OTF build ---
590
  otf_bytes = build_otf([img1, img2, img3], font_name)
591
 
592
  out_dir = tempfile.mkdtemp()
 
594
  with open(otf_path, "wb") as f:
595
  f.write(otf_bytes)
596
 
597
+ gallery = [(img1, "Uppercase"), (img2, "Lowercase"), (img3, "Punctuation")]
598
  preview = _render_preview(otf_path, font_name)
599
+ status = f"<span class='dot'></span> <b>{font_name}</b> &nbsp;·&nbsp; seed <code>{seed}</code>"
600
  return gallery, preview, otf_path, status
601
 
602
 
603
+ # =============================================================================
604
+ # THEME — type-foundry / specimen-sheet look (ink + paper + vermillion)
605
+ # =============================================================================
606
+ THEME = gr.themes.Base(
607
+ primary_hue=gr.themes.colors.red,
608
+ neutral_hue=gr.themes.colors.stone,
609
+ font=[gr.themes.GoogleFont("Instrument Sans"), "ui-sans-serif", "sans-serif"],
610
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
611
+ ).set(
612
+ body_background_fill="#0E0E0D",
613
+ body_background_fill_dark="#0E0E0D",
614
+ body_text_color="#EDE8DC",
615
+ body_text_color_dark="#EDE8DC",
616
+ body_text_color_subdued="#8A857A",
617
+ body_text_color_subdued_dark="#8A857A",
618
+ background_fill_primary="#141413",
619
+ background_fill_primary_dark="#141413",
620
+ background_fill_secondary="#1B1A18",
621
+ background_fill_secondary_dark="#1B1A18",
622
+ block_background_fill="#141413",
623
+ block_background_fill_dark="#141413",
624
+ block_border_color="#2A2825",
625
+ block_border_color_dark="#2A2825",
626
+ block_border_width="1px",
627
+ block_label_text_color="#8A857A",
628
+ block_label_text_color_dark="#8A857A",
629
+ block_label_background_fill="#141413",
630
+ block_label_background_fill_dark="#141413",
631
+ block_title_text_color="#EDE8DC",
632
+ block_title_text_color_dark="#EDE8DC",
633
+ block_radius="3px",
634
+ input_background_fill="#1B1A18",
635
+ input_background_fill_dark="#1B1A18",
636
+ input_border_color="#2A2825",
637
+ input_border_color_dark="#2A2825",
638
+ input_border_color_focus="#FF5128",
639
+ input_border_color_focus_dark="#FF5128",
640
+ button_primary_background_fill="#FF5128",
641
+ button_primary_background_fill_dark="#FF5128",
642
+ button_primary_background_fill_hover="#FF6B47",
643
+ button_primary_background_fill_hover_dark="#FF6B47",
644
+ button_primary_text_color="#0E0E0D",
645
+ button_primary_text_color_dark="#0E0E0D",
646
+ button_primary_border_color="#FF5128",
647
+ button_primary_border_color_dark="#FF5128",
648
+ )
649
+
650
+ CSS = """
651
+ @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,600;1,9..144,900&family=JetBrains+Mono:wght@400;600&display=swap');
652
+
653
+ .gradio-container {
654
+ max-width: 1200px !important;
655
+ margin: 0 auto !important;
656
+ background:
657
+ radial-gradient(1100px 520px at 88% -12%, rgba(255,81,40,0.12), transparent 60%),
658
+ radial-gradient(820px 460px at -8% 112%, rgba(255,81,40,0.07), transparent 55%) !important;
659
+ }
660
+
661
+ /* Hero */
662
+ #hero { padding: 22px 4px 26px; border-bottom: 1px solid #2A2825; margin-bottom: 26px; }
663
+ #hero .kicker {
664
+ font-family: 'JetBrains Mono', monospace;
665
+ text-transform: uppercase; letter-spacing: 0.34em;
666
+ font-size: 10px; color: #FF5128; margin: 0 0 16px;
667
+ }
668
+ #hero h1 {
669
+ font-family: 'Fraunces', Georgia, serif;
670
+ font-weight: 900; font-size: clamp(56px, 11vw, 132px);
671
+ line-height: 0.84; letter-spacing: -0.035em; margin: 0; color: #EDE8DC;
672
+ }
673
+ #hero h1 em { font-style: italic; color: #FF5128; }
674
+ #hero .tag {
675
+ font-family: 'JetBrains Mono', monospace;
676
+ font-size: 12px; line-height: 1.7; letter-spacing: 0.02em;
677
+ color: #8A857A; margin-top: 18px; max-width: 620px;
678
+ }
679
+ #hero .tag b { color: #EDE8DC; font-weight: 600; }
680
+
681
+ /* Section eyebrow labels */
682
+ .eyebrow {
683
+ font-family: 'JetBrains Mono', monospace !important;
684
+ text-transform: uppercase; letter-spacing: 0.22em;
685
+ font-size: 10px !important; color: #6E6A60 !important; margin: 4px 0 2px !important;
686
+ }
687
+
688
+ /* Component labels in mono */
689
+ .gradio-container label span,
690
+ .gradio-container .gr-check-radio label span {
691
+ font-family: 'JetBrains Mono', monospace !important;
692
+ letter-spacing: 0.06em; font-size: 11px !important; color: #8A857A !important;
693
+ }
694
+
695
+ /* Primary CTA */
696
+ .go-btn {
697
+ text-transform: uppercase !important;
698
+ letter-spacing: 0.16em !important;
699
+ font-family: 'JetBrains Mono', monospace !important;
700
+ font-weight: 600 !important;
701
+ font-size: 13px !important;
702
+ border-radius: 3px !important;
703
+ min-height: 52px !important;
704
+ box-shadow: 0 0 0 1px rgba(255,81,40,0.4), 0 12px 30px -12px rgba(255,81,40,0.6) !important;
705
+ transition: transform .12s ease, box-shadow .2s ease !important;
706
+ }
707
+ .go-btn:hover { transform: translateY(-1px); }
708
+
709
+ /* Status line */
710
+ .status-line { min-height: 22px; }
711
+ .status-line p {
712
+ font-family: 'JetBrains Mono', monospace !important;
713
+ font-size: 13px !important; color: #EDE8DC !important; margin: 0 !important;
714
+ }
715
+ .status-line code { color: #FF5128; background: transparent; }
716
+ .status-line .dot {
717
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%;
718
+ background: #FF5128; margin-right: 8px;
719
+ box-shadow: 0 0 10px rgba(255,81,40,0.9);
720
+ }
721
+
722
+ footer { display: none !important; }
723
+ """
724
+
725
+ with gr.Blocks(title="Typotopia", theme=THEME, css=CSS) as demo:
726
+ gr.HTML(
727
+ """
728
+ <div id="hero">
729
+ <p class="kicker">Generative type foundry</p>
730
+ <h1>Typo<em>topia</em></h1>
731
+ <p class="tag">
732
+ From a single <b>prompt</b> or a <b>control image</b> to a complete OpenType file.
733
+ FLUX.2-klein + custom LoRAs on <b>ZeroGPU</b> &rarr; potrace vectorization
734
+ &rarr; fontTools assembly with pure-vector kerning.
735
+ </p>
736
+ </div>
737
+ """
738
  )
739
+ with gr.Row(equal_height=False):
740
  with gr.Column(scale=1):
741
+ gr.HTML("<p class='eyebrow'>01 — Brief</p>")
742
  prompt = gr.Textbox(
743
  label="Prompt",
744
+ placeholder="e.g. a bold geometric sans inspired by brutalist concrete signage",
745
  lines=3,
746
  )
747
+ control_image = gr.Image(label="Control image (optional)", type="pil")
748
  with gr.Row():
749
  seed = gr.Number(label="Seed", value=0, precision=0)
750
+ random_seed = gr.Checkbox(label="Random seed", value=True)
751
+ btn = gr.Button("Generate font", variant="primary", elem_classes=["go-btn"])
752
  with gr.Column(scale=1):
753
+ gr.HTML("<p class='eyebrow'>02 — Specimen</p>")
754
+ status = gr.HTML(elem_classes=["status-line"])
755
+ otf_file = gr.File(label="Download .otf")
756
+ preview = gr.Image(label="Preview", type="pil")
757
+ gallery = gr.Gallery(label="Generated grids", columns=3, height="auto")
758
 
759
  btn.click(
760
  generate,
 
763
  )
764
 
765
  if __name__ == "__main__":
766
+ demo.queue().launch()