ChevalierJoseph commited on
Commit
2601d8e
·
verified ·
1 Parent(s): 5709b50

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +376 -279
app.py CHANGED
@@ -2,6 +2,9 @@
2
  import os, io, re, random, string, tempfile
3
  import spaces
4
  import gradio as gr
 
 
 
5
  from PIL import Image
6
 
7
  # --- CONSTANTS ---
@@ -546,7 +549,7 @@ 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:
@@ -574,45 +577,10 @@ def _render_preview(otf_path, font_name):
574
  return None
575
 
576
 
577
- def generate(prompt, control_image, progress=gr.Progress(track_tqdm=True)):
578
- seed = random.randint(0, 2**32 - 1)
579
-
580
- input_img = None
581
- prefixes = ["Vex","Aur","Kyr","Lux","Nox","Arc","Sol","Vel","Fen","Zor","Cal","Dex","Ora","Pax","Rux"]
582
- suffixes = ["ra","is","on","us","ia","el","an","ix","em","or","al","en","ax","um","yr"]
583
- font_name = random.choice(prefixes) + random.choice(suffixes)
584
-
585
- if control_image is not None:
586
- input_img = control_image.convert("RGB").resize((1536, 1536))
587
- enhanced = DEFAULT_IMG_PROMPT
588
- else:
589
- if not (prompt and prompt.strip()):
590
- raise gr.Error("Provide a prompt OR a control image.")
591
- enhanced = prompt # raw user input, no refinement
592
-
593
- # --- GPU: 3 FLUX passes ---
594
- img1, img2, img3 = run_pipeline(enhanced, input_img, seed)
595
-
596
- if not font_name:
597
- font_name = "Font" + "".join(random.choices(string.ascii_uppercase, k=3))
598
-
599
- # --- CPU: potrace vectorization + OTF build ---
600
- otf_bytes = build_otf([img1, img2, img3], font_name)
601
-
602
- out_dir = tempfile.mkdtemp()
603
- otf_path = os.path.join(out_dir, f"{font_name}.otf")
604
- with open(otf_path, "wb") as f:
605
- f.write(otf_bytes)
606
-
607
- gallery = [(img1, "Uppercase"), (img2, "Lowercase"), (img3, "Punctuation")]
608
- preview = _render_preview(otf_path, font_name)
609
- return gallery, preview, otf_path
610
-
611
-
612
  # =============================================================================
613
  # OPTIONAL CUSTOM TITLE FONT
614
- # Drop your own font in the repo as one of these names and the hero title will
615
- # use it (otherwise it falls back to the Helvetica/Archivo grotesque stack):
616
  # title.otf / title.ttf / title.woff2 / title.woff (or under assets/)
617
  # =============================================================================
618
  def _load_title_font():
@@ -640,260 +608,389 @@ def _load_title_font():
640
 
641
  TITLE_FONT_FACE, TITLE_FAMILY = _load_title_font()
642
 
 
643
  # =============================================================================
644
- # THEME International Typographic Style (light grey / black / violet)
645
  # =============================================================================
646
- THEME = gr.themes.Base(
647
- primary_hue=gr.themes.colors.violet,
648
- neutral_hue=gr.themes.colors.gray,
649
- radius_size=gr.themes.sizes.radius_none,
650
- font=["Helvetica Neue", "Helvetica", gr.themes.GoogleFont("Archivo"), "Arial", "sans-serif"],
651
- font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
652
- ).set(
653
- body_background_fill="#ECECEC",
654
- body_background_fill_dark="#ECECEC",
655
- body_text_color="#000000",
656
- body_text_color_dark="#000000",
657
- body_text_color_subdued="#6B6B6B",
658
- body_text_color_subdued_dark="#6B6B6B",
659
- background_fill_primary="#FFFFFF",
660
- background_fill_primary_dark="#FFFFFF",
661
- background_fill_secondary="#F4F4F4",
662
- background_fill_secondary_dark="#F4F4F4",
663
- block_background_fill="#FFFFFF",
664
- block_background_fill_dark="#FFFFFF",
665
- block_border_color="#D8D8D8",
666
- block_border_color_dark="#D8D8D8",
667
- block_border_width="1px",
668
- block_label_text_color="#000000",
669
- block_label_text_color_dark="#000000",
670
- block_label_background_fill="#FFFFFF",
671
- block_label_background_fill_dark="#FFFFFF",
672
- block_title_text_color="#000000",
673
- block_title_text_color_dark="#000000",
674
- block_radius="0px",
675
- block_shadow="none",
676
- block_shadow_dark="none",
677
- input_background_fill="#FFFFFF",
678
- input_background_fill_dark="#FFFFFF",
679
- input_border_color="#000000",
680
- input_border_color_dark="#000000",
681
- input_border_color_focus="#5F2EEA",
682
- input_border_color_focus_dark="#5F2EEA",
683
- input_shadow="none",
684
- input_shadow_dark="none",
685
- input_shadow_focus="none",
686
- input_shadow_focus_dark="none",
687
- shadow_drop="none",
688
- shadow_drop_lg="none",
689
- button_primary_background_fill="#5F2EEA",
690
- button_primary_background_fill_dark="#5F2EEA",
691
- button_primary_background_fill_hover="#000000",
692
- button_primary_background_fill_hover_dark="#000000",
693
- button_primary_text_color="#FFFFFF",
694
- button_primary_text_color_dark="#FFFFFF",
695
- button_primary_border_color="#5F2EEA",
696
- button_primary_border_color_dark="#5F2EEA",
697
- button_secondary_background_fill="#FFFFFF",
698
- button_secondary_background_fill_dark="#FFFFFF",
699
- button_secondary_text_color="#000000",
700
- button_secondary_text_color_dark="#000000",
701
- button_secondary_border_color="#000000",
702
- button_secondary_border_color_dark="#000000",
703
- )
704
 
705
- CSS = """
706
- @import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
707
-
708
- :root {
709
- --ink: #000000;
710
- --paper: #FFFFFF;
711
- --bg: #ECECEC;
712
- --accent: #5F2EEA;
713
- --grey: #6B6B6B;
714
- --hairline: #D8D8D8;
715
- --grotesk: 'Helvetica Neue', Helvetica, 'Archivo', Arial, sans-serif;
716
- --mono: 'JetBrains Mono', ui-monospace, monospace;
717
- }
718
 
719
- .gradio-container {
720
- max-width: 100% !important;
721
- width: 100% !important;
722
- margin: 0 !important;
723
- padding-left: clamp(20px, 5vw, 72px) !important;
724
- padding-right: clamp(20px, 5vw, 72px) !important;
725
- background: var(--bg) !important;
726
- }
727
 
728
- /* Kill every radius and shadow — flat ink on paper */
729
- .gradio-container *,
730
- .gradio-container *::before,
731
- .gradio-container *::after {
732
- border-radius: 0 !important;
733
- box-shadow: none !important;
734
- text-shadow: none !important;
735
- }
736
 
737
- /* =========================================================================
738
- MASTHEAD wordmark left, tabular meta right, heavy rule below
739
- ========================================================================= */
740
- #hero { padding: 22px 0 0; margin-bottom: 18px; }
741
- #hero .masthead {
742
- display: flex;
743
- justify-content: space-between;
744
- align-items: flex-end;
745
- gap: 32px;
746
- flex-wrap: wrap;
747
- padding-bottom: 8px;
748
- }
749
- #hero h1 {
750
- font-family: __TITLE_FAMILY__;
751
- font-weight: 700;
752
- font-size: clamp(44px, 8.5vw, 108px);
753
- line-height: 0.82;
754
- letter-spacing: -0.05em;
755
- margin: 0;
756
- color: var(--ink);
757
- }
758
- #hero h1 .stop { color: var(--accent); }
759
- #hero .meta {
760
- font-family: var(--grotesk);
761
- font-size: 11px;
762
- font-weight: 500;
763
- text-transform: uppercase;
764
- letter-spacing: 0.14em;
765
- line-height: 1;
766
- color: var(--ink);
767
- min-width: 280px;
768
- flex: 0 1 340px;
769
- }
770
- #hero .meta .row {
771
- display: flex;
772
- justify-content: space-between;
773
- gap: 24px;
774
- padding: 7px 0;
775
- border-top: 1px solid var(--ink);
776
- }
777
- #hero .meta .row:first-child { border-top: none; }
778
- #hero .meta .k { color: var(--grey); }
779
- #hero .meta .v { text-align: right; }
780
-
781
- /* Component labels */
782
- .gradio-container label span,
783
- .gradio-container .gr-check-radio label span {
784
- font-family: var(--grotesk) !important;
785
- text-transform: uppercase;
786
- letter-spacing: 0.12em;
787
- font-size: 10px !important;
788
- font-weight: 500 !important;
789
- color: var(--ink) !important;
790
- }
791
 
792
- /* Inputs: ink rules on paper */
793
- .gradio-container textarea,
794
- .gradio-container input[type='text'],
795
- .gradio-container input[type='number'] {
796
- font-family: var(--grotesk) !important;
797
- font-size: 14px !important;
798
- border: 1px solid var(--ink) !important;
799
- background: var(--paper) !important;
800
- }
801
- .gradio-container textarea:focus,
802
- .gradio-container input:focus {
803
- border-color: var(--accent) !important;
804
- outline: 2px solid var(--accent) !important;
805
- outline-offset: -1px;
806
- }
807
 
808
- /* Primary CTA — violet plate, uppercase, no ornament */
809
- .go-btn {
810
- text-transform: uppercase !important;
811
- letter-spacing: 0.22em !important;
812
- font-family: var(--grotesk) !important;
813
- font-weight: 700 !important;
814
- font-size: 12px !important;
815
- min-height: 48px !important;
816
- border: none !important;
817
- transition: background-color .12s ease !important;
818
- }
819
- .go-btn:hover { background: var(--ink) !important; }
820
- .go-btn:focus-visible { outline: 2px solid var(--ink) !important; outline-offset: 2px; }
821
-
822
- /* Compaction single-screen fit */
823
- #hero ~ .row, .gradio-container .gap { gap: 12px !important; }
824
- .gradio-container .padded, .gradio-container .block { padding: 10px !important; }
825
- .gradio-container .block { border-color: var(--hairline) !important; }
826
-
827
- /* Control image dropzone — icon only, no instruction text */
828
- #control-image .wrap {
829
- font-size: 0 !important;
830
- line-height: 0 !important;
831
- gap: 0 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
832
  }
833
- #control-image .wrap .or { display: none !important; }
834
- #control-image .wrap svg { width: 28px !important; height: 28px !important; }
835
-
836
- /* Compact output blocks (download + preview) */
837
- #otf-file { max-height: 84px !important; overflow: hidden; }
838
- #otf-file .unpadded_box, #otf-file .empty { min-height: 56px !important; height: 56px !important; }
839
- #preview-img .unpadded_box, #preview-img .empty { min-height: 96px !important; }
840
-
841
- /* Progress — single violet gauge inside the grids block */
842
- .gradio-container .progress-bar { background: var(--accent) !important; }
843
- #grids { min-height: 240px; }
844
- #grids .progress-text, #grids .progress-level-inner, #grids .meta-text, #grids .meta-text-center {
845
- font-family: var(--mono) !important;
846
- font-size: 12px !important;
847
- color: var(--ink) !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
848
  }
849
 
850
- @media (prefers-reduced-motion: reduce) {
851
- .gradio-container * { transition: none !important; animation: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  }
853
 
854
- footer { display: none !important; }
 
 
 
855
  """
856
 
857
- # Inject the chosen title font-family + (optional) embedded @font-face.
858
- CSS = CSS.replace("__TITLE_FAMILY__", TITLE_FAMILY) + TITLE_FONT_FACE
859
-
860
- with gr.Blocks(title="Typotopia", theme=THEME, css=CSS) as demo:
861
- gr.HTML(
862
- """
863
- <div id="hero">
864
- <div class="masthead">
865
- <h1>Typotopia<span class="stop">.</span></h1>
866
- <div class="meta">
867
- <div class="row"><span class="k">Input</span><span class="v">Prompt / Control image</span></div>
868
- <div class="row"><span class="k">Model</span><span class="v">FLUX.2-klein + LoRA</span></div>
869
- <div class="row"><span class="k">Output</span><span class="v">OpenType · GPOS kerning</span></div>
870
- </div>
871
- </div>
872
- </div>
873
- """
874
- )
875
- with gr.Row(equal_height=False):
876
- with gr.Column(scale=1):
877
- prompt = gr.Textbox(
878
- label="Prompt",
879
- placeholder="e.g. a bold geometric sans inspired by brutalist concrete signage",
880
- lines=2,
881
- )
882
- control_image = gr.Image(label="Control image (optional)", type="pil", height=140, elem_id="control-image")
883
- btn = gr.Button("Generate font", variant="primary", elem_classes=["go-btn"])
884
- with gr.Column(scale=1):
885
- otf_file = gr.File(label="Download .otf", height=56, elem_id="otf-file")
886
- preview = gr.Image(label="Preview", type="pil", height=150, elem_id="preview-img")
887
- gallery = gr.Gallery(label="Generated grids", columns=3, height=150, object_fit="contain", elem_id="grids")
888
-
889
- btn.click(
890
- generate,
891
- inputs=[prompt, control_image],
892
- outputs=[gallery, preview, otf_file],
893
- show_progress="full",
894
- show_progress_on=gallery,
895
- )
896
 
897
- if __name__ == "__main__":
898
- demo.queue().launch()
899
-
 
2
  import os, io, re, random, string, tempfile
3
  import spaces
4
  import gradio as gr
5
+ from gradio import Server
6
+ from gradio.data_classes import FileData
7
+ from fastapi.responses import HTMLResponse
8
  from PIL import Image
9
 
10
  # --- CONSTANTS ---
 
549
 
550
 
551
  # =============================================================================
552
+ # PREVIEW RENDER (CPU)
553
  # =============================================================================
554
  def _render_preview(otf_path, font_name):
555
  try:
 
577
  return None
578
 
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  # =============================================================================
581
  # OPTIONAL CUSTOM TITLE FONT
582
+ # Drop your own font in the repo as one of these names and the hero wordmark
583
+ # will use it (otherwise it falls back to the Helvetica/Archivo stack):
584
  # title.otf / title.ttf / title.woff2 / title.woff (or under assets/)
585
  # =============================================================================
586
  def _load_title_font():
 
608
 
609
  TITLE_FONT_FACE, TITLE_FAMILY = _load_title_font()
610
 
611
+
612
  # =============================================================================
613
+ # BACKEND SERVER (gradio.Server == FastAPI + Gradio's queue / ZeroGPU engine)
614
  # =============================================================================
615
+ app = Server()
616
+
617
+
618
+ @app.api(name="generate", concurrency_limit=1)
619
+ def generate(prompt: str = "", control_image: FileData = None):
620
+ """
621
+ Returns a positional tuple -> arrives on the JS client as result.data:
622
+ data[0] uppercase grid (FileData .url)
623
+ data[1] lowercase grid (FileData .url)
624
+ data[2] punctuation grid (FileData .url)
625
+ data[3] specimen preview (FileData .url)
626
+ data[4] .otf file (FileData .url)
627
+ data[5] font name (str)
628
+ """
629
+ seed = random.randint(0, 2**32 - 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
 
631
+ input_img = None
632
+ prefixes = ["Vex","Aur","Kyr","Lux","Nox","Arc","Sol","Vel","Fen","Zor","Cal","Dex","Ora","Pax","Rux"]
633
+ suffixes = ["ra","is","on","us","ia","el","an","ix","em","or","al","en","ax","um","yr"]
634
+ font_name = random.choice(prefixes) + random.choice(suffixes)
 
 
 
 
 
 
 
 
 
635
 
636
+ if control_image is not None:
637
+ input_img = Image.open(control_image["path"]).convert("RGB").resize((1536, 1536))
638
+ enhanced = DEFAULT_IMG_PROMPT
639
+ else:
640
+ if not (prompt and prompt.strip()):
641
+ raise gr.Error("Provide a prompt OR a control image.")
642
+ enhanced = prompt # raw user input, no refinement
 
643
 
644
+ # --- GPU: 3 FLUX passes ---
645
+ img1, img2, img3 = run_pipeline(enhanced, input_img, seed)
 
 
 
 
 
 
646
 
647
+ if not font_name:
648
+ font_name = "Font" + "".join(random.choices(string.ascii_uppercase, k=3))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
650
+ # --- CPU: potrace vectorization + OTF build ---
651
+ otf_bytes = build_otf([img1, img2, img3], font_name)
 
 
 
 
 
 
 
 
 
 
 
 
 
652
 
653
+ out_dir = tempfile.mkdtemp()
654
+ otf_path = os.path.join(out_dir, f"{font_name}.otf")
655
+ with open(otf_path, "wb") as f:
656
+ f.write(otf_bytes)
657
+
658
+ uc_path = os.path.join(out_dir, "uppercase.png"); img1.save(uc_path)
659
+ lc_path = os.path.join(out_dir, "lowercase.png"); img2.save(lc_path)
660
+ pn_path = os.path.join(out_dir, "punctuation.png"); img3.save(pn_path)
661
+
662
+ preview = _render_preview(otf_path, font_name)
663
+ prev_path = os.path.join(out_dir, "preview.png")
664
+ if preview is not None:
665
+ preview.save(prev_path)
666
+ else:
667
+ Image.new("RGB", (1200, 300), "white").save(prev_path)
668
+
669
+ return (
670
+ FileData(path=uc_path),
671
+ FileData(path=lc_path),
672
+ FileData(path=pn_path),
673
+ FileData(path=prev_path),
674
+ FileData(path=otf_path),
675
+ font_name,
676
+ )
677
+
678
+
679
+ # =============================================================================
680
+ # FRONTEND — React (CDN + in-browser Babel, no build step), served inline.
681
+ # Talks to the backend through the Gradio JS client so requests go through the
682
+ # queue and ZeroGPU auth headers are forwarded (a raw fetch() would break it).
683
+ # =============================================================================
684
+ FRONTEND_HTML = r"""<!DOCTYPE html>
685
+ <html lang="en">
686
+ <head>
687
+ <meta charset="utf-8" />
688
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
689
+ <title>Typotopia</title>
690
+ <link rel="preconnect" href="https://fonts.googleapis.com">
691
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
692
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
693
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
694
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
695
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
696
+ <style>
697
+ /*__TITLE_FONT_FACE__*/
698
+ :root{
699
+ --ink:#000; --paper:#fff; --bg:#ECECEC; --accent:#5F2EEA;
700
+ --grey:#6B6B6B; --hairline:#D8D8D8;
701
+ --grotesk:'Helvetica Neue',Helvetica,'Archivo',Arial,sans-serif;
702
+ --mono:'JetBrains Mono',ui-monospace,monospace;
703
+ --title:__TITLE_FAMILY__;
704
  }
705
+ *{box-sizing:border-box;margin:0;padding:0;}
706
+ html,body{background:var(--bg);color:var(--ink);font-family:var(--grotesk);-webkit-font-smoothing:antialiased;}
707
+ ::selection{background:var(--accent);color:#fff;}
708
+ a{color:inherit;}
709
+ .wrap{max-width:1500px;margin:0 auto;padding:clamp(20px,4vw,56px) clamp(20px,5vw,72px) 80px;}
710
+
711
+ /* ---- masthead ---- */
712
+ .masthead{display:flex;justify-content:space-between;align-items:flex-end;gap:32px;flex-wrap:wrap;
713
+ padding-bottom:14px;border-bottom:2px solid var(--ink);}
714
+ .wordmark{font-family:var(--title);font-weight:800;line-height:.8;letter-spacing:-.05em;
715
+ font-size:clamp(48px,9vw,124px);}
716
+ .wordmark .stop{color:var(--accent);}
717
+ .meta{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;
718
+ min-width:300px;flex:0 1 360px;}
719
+ .meta .row{display:flex;justify-content:space-between;gap:24px;padding:7px 0;border-top:1px solid var(--ink);}
720
+ .meta .row:first-child{border-top:none;}
721
+ .meta .k{color:var(--grey);}
722
+ .meta .v{text-align:right;}
723
+
724
+ /* ---- working grid ---- */
725
+ .work{display:grid;grid-template-columns:0.92fr 1.18fr;gap:0;margin-top:28px;border:1px solid var(--ink);}
726
+ .col{padding:clamp(18px,2.4vw,34px);}
727
+ .col.left{border-right:1px solid var(--ink);background:var(--paper);}
728
+ .col.right{background:var(--paper);}
729
+ @media(max-width:880px){.work{grid-template-columns:1fr;}.col.left{border-right:none;border-bottom:1px solid var(--ink);}}
730
+
731
+ .label{font-family:var(--mono);font-size:10px;font-weight:600;text-transform:uppercase;
732
+ letter-spacing:.16em;color:var(--ink);display:block;margin-bottom:10px;}
733
+ .sub{color:var(--grey);}
734
+
735
+ textarea{width:100%;min-height:120px;resize:vertical;border:1px solid var(--ink);background:var(--paper);
736
+ font-family:var(--grotesk);font-size:15px;line-height:1.45;padding:14px;outline:none;color:var(--ink);}
737
+ textarea:focus{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent);}
738
+ textarea::placeholder{color:var(--grey);}
739
+
740
+ .dz{margin-top:22px;border:1px dashed var(--ink);background:var(--paper);min-height:120px;
741
+ display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative;
742
+ transition:border-color .12s,background .12s;}
743
+ .dz:hover,.dz.over{border-color:var(--accent);background:#FAF8FF;}
744
+ .dz .dzhint{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--grey);}
745
+ .dz img{max-width:100%;max-height:200px;display:block;}
746
+ .dz .clear{position:absolute;top:6px;right:6px;width:26px;height:26px;border:1px solid var(--ink);
747
+ background:var(--paper);font-family:var(--mono);font-size:13px;line-height:1;cursor:pointer;
748
+ display:flex;align-items:center;justify-content:center;}
749
+ .dz .clear:hover{background:var(--ink);color:var(--paper);}
750
+
751
+ .go{margin-top:24px;width:100%;min-height:54px;border:none;background:var(--accent);color:#fff;
752
+ font-family:var(--grotesk);font-weight:700;font-size:13px;text-transform:uppercase;letter-spacing:.22em;
753
+ cursor:pointer;transition:background .12s;}
754
+ .go:hover{background:var(--ink);}
755
+ .go:disabled{background:var(--grey);cursor:not-allowed;}
756
+
757
+ .err{margin-top:14px;font-family:var(--mono);font-size:12px;color:#B00020;letter-spacing:.04em;}
758
+
759
+ /* ---- output ---- */
760
+ .placeholder{height:100%;min-height:280px;display:flex;align-items:center;justify-content:center;
761
+ font-family:var(--mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--hairline);}
762
+
763
+ .specimen{border:1px solid var(--ink);background:var(--paper);overflow:hidden;}
764
+ .specimen .bar{display:flex;justify-content:space-between;align-items:baseline;
765
+ padding:10px 14px;border-bottom:1px solid var(--ink);}
766
+ .specimen .name{font-family:var(--title);font-weight:800;font-size:22px;letter-spacing:-.02em;}
767
+ .specimen .tag{font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--grey);}
768
+ .specimen .canvas{padding:18px 14px;display:flex;align-items:center;justify-content:center;background:var(--paper);}
769
+ .specimen .canvas img{width:100%;height:auto;display:block;}
770
+
771
+ .dl{margin-top:18px;display:flex;align-items:stretch;border:1px solid var(--ink);background:var(--paper);}
772
+ .dl .info{flex:1;padding:12px 16px;}
773
+ .dl .info .fn{font-family:var(--mono);font-size:14px;font-weight:600;letter-spacing:.02em;}
774
+ .dl .info .ft{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--grey);margin-top:3px;}
775
+ .dl a.btn{display:flex;align-items:center;padding:0 26px;background:var(--ink);color:#fff;
776
+ text-decoration:none;font-family:var(--grotesk);font-weight:700;font-size:12px;
777
+ text-transform:uppercase;letter-spacing:.18em;transition:background .12s;}
778
+ .dl a.btn:hover{background:var(--accent);}
779
+
780
+ .grids{margin-top:18px;display:grid;grid-template-columns:repeat(3,1fr);gap:12px;}
781
+ .gridcard{border:1px solid var(--ink);background:var(--paper);}
782
+ .gridcard .gl{font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;
783
+ padding:7px 9px;border-bottom:1px solid var(--ink);color:var(--grey);}
784
+ .gridcard img{width:100%;height:auto;display:block;}
785
+ @media(max-width:540px){.grids{grid-template-columns:1fr;}}
786
+
787
+ /* ---- loading ---- */
788
+ .loading{height:100%;min-height:300px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;}
789
+ .scan{width:64px;height:64px;border:2px solid var(--ink);position:relative;overflow:hidden;}
790
+ .scan::after{content:"";position:absolute;left:0;right:0;top:0;height:2px;background:var(--accent);
791
+ animation:scan 1.1s ease-in-out infinite;}
792
+ @keyframes scan{0%{top:0}50%{top:calc(100% - 2px)}100%{top:0}}
793
+ .lmsg{font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);}
794
+ .lsub{font-family:var(--mono);font-size:10px;letter-spacing:.12em;color:var(--grey);}
795
+
796
+ .foot{margin-top:40px;font-family:var(--mono);font-size:10px;letter-spacing:.14em;
797
+ text-transform:uppercase;color:var(--grey);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;}
798
+ @media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;}}
799
+ </style>
800
+ </head>
801
+ <body>
802
+ <div id="root"></div>
803
+
804
+ <script type="text/babel" data-type="module" data-presets="react">
805
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
806
+
807
+ const { useState, useRef, useEffect, useCallback } = React;
808
+
809
+ const STAGES = [
810
+ "Generating uppercase",
811
+ "Generating lowercase",
812
+ "Generating punctuation",
813
+ "Vectorizing contours",
814
+ "Computing bubble kerning",
815
+ "Assembling OpenType",
816
+ ];
817
+
818
+ let clientPromise = null;
819
+ function getClient(){
820
+ if(!clientPromise) clientPromise = Client.connect(window.location.origin);
821
+ return clientPromise;
822
  }
823
 
824
+ function App(){
825
+ const [prompt,setPrompt] = useState("");
826
+ const [file,setFile] = useState(null);
827
+ const [fileUrl,setFileUrl] = useState(null);
828
+ const [over,setOver] = useState(false);
829
+ const [loading,setLoading] = useState(false);
830
+ const [stage,setStage] = useState(0);
831
+ const [err,setErr] = useState("");
832
+ const [res,setRes] = useState(null);
833
+ const inputRef = useRef(null);
834
+
835
+ useEffect(()=>{
836
+ if(!loading) return;
837
+ setStage(0);
838
+ const id = setInterval(()=>setStage(s=>Math.min(s+1, STAGES.length-1)), 12000);
839
+ return ()=>clearInterval(id);
840
+ },[loading]);
841
+
842
+ const pickFile = (f)=>{
843
+ if(!f) return;
844
+ setFile(f);
845
+ if(fileUrl) URL.revokeObjectURL(fileUrl);
846
+ setFileUrl(URL.createObjectURL(f));
847
+ };
848
+ const clearFile = (e)=>{
849
+ e.stopPropagation();
850
+ setFile(null);
851
+ if(fileUrl) URL.revokeObjectURL(fileUrl);
852
+ setFileUrl(null);
853
+ if(inputRef.current) inputRef.current.value = "";
854
+ };
855
+
856
+ const onDrop = useCallback((e)=>{
857
+ e.preventDefault(); setOver(false);
858
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
859
+ if(f && f.type.startsWith("image/")) pickFile(f);
860
+ },[fileUrl]);
861
+
862
+ const run = async ()=>{
863
+ setErr("");
864
+ if(!file && !prompt.trim()){
865
+ setErr("Provide a prompt or a control image.");
866
+ return;
867
+ }
868
+ setLoading(true); setRes(null);
869
+ try{
870
+ const client = await getClient();
871
+ const payload = { prompt: prompt };
872
+ if(file) payload.control_image = handle_file(file);
873
+ const result = await client.predict("/generate", payload);
874
+ const d = result.data;
875
+ setRes({
876
+ uc: d[0].url,
877
+ lc: d[1].url,
878
+ punc: d[2].url,
879
+ preview: d[3].url,
880
+ otf: d[4].url,
881
+ name: d[5],
882
+ });
883
+ }catch(ex){
884
+ console.error(ex);
885
+ const msg = (ex && (ex.message || (ex.detail && (ex.detail.message||ex.detail)))) || "Generation failed.";
886
+ setErr(String(msg));
887
+ }finally{
888
+ setLoading(false);
889
+ }
890
+ };
891
+
892
+ return (
893
+ <div className="wrap">
894
+ <header className="masthead">
895
+ <div className="wordmark">Typotopia<span className="stop">.</span></div>
896
+ <div className="meta">
897
+ <div className="row"><span className="k">Input</span><span className="v">Prompt / Control image</span></div>
898
+ <div className="row"><span className="k">Model</span><span className="v">FLUX.2-klein · 3× LoRA</span></div>
899
+ <div className="row"><span className="k">Output</span><span className="v">OpenType · GPOS kerning</span></div>
900
+ </div>
901
+ </header>
902
+
903
+ <div className="work">
904
+ <section className="col left">
905
+ <label className="label">Prompt <span className="sub">/ describe the typeface</span></label>
906
+ <textarea
907
+ value={prompt}
908
+ onChange={e=>setPrompt(e.target.value)}
909
+ placeholder="e.g. a bold geometric sans inspired by brutalist concrete signage"
910
+ />
911
+
912
+ <label className="label" style={{marginTop:22}}>Control image <span className="sub">/ optional</span></label>
913
+ <div
914
+ className={"dz"+(over?" over":"")}
915
+ onClick={()=>inputRef.current && inputRef.current.click()}
916
+ onDragOver={e=>{e.preventDefault();setOver(true);}}
917
+ onDragLeave={()=>setOver(false)}
918
+ onDrop={onDrop}
919
+ >
920
+ {fileUrl
921
+ ? (<React.Fragment><img src={fileUrl} alt="control" /><button className="clear" onClick={clearFile} title="Remove">×</button></React.Fragment>)
922
+ : (<span className="dzhint">Drop image · or click</span>)}
923
+ <input ref={inputRef} type="file" accept="image/*" hidden
924
+ onChange={e=>pickFile(e.target.files && e.target.files[0])} />
925
+ </div>
926
+
927
+ <button className="go" onClick={run} disabled={loading}>
928
+ {loading ? "Generating…" : "Generate font"}
929
+ </button>
930
+ {err && <div className="err">{err}</div>}
931
+ </section>
932
+
933
+ <section className="col right">
934
+ {loading ? (
935
+ <div className="loading">
936
+ <div className="scan"></div>
937
+ <div className="lmsg">{STAGES[stage]}…</div>
938
+ <div className="lsub">This takes ~1–2 min on ZeroGPU</div>
939
+ </div>
940
+ ) : res ? (
941
+ <React.Fragment>
942
+ <div className="specimen">
943
+ <div className="bar">
944
+ <span className="name">{res.name}</span>
945
+ <span className="tag">Specimen · Regular</span>
946
+ </div>
947
+ <div className="canvas"><img src={res.preview} alt="specimen" /></div>
948
+ </div>
949
+
950
+ <div className="dl">
951
+ <div className="info">
952
+ <div className="fn">{res.name}.otf</div>
953
+ <div className="ft">OpenType · CFF · GPOS</div>
954
+ </div>
955
+ <a className="btn" href={res.otf} download={res.name + ".otf"}>Download</a>
956
+ </div>
957
+
958
+ <div className="grids">
959
+ <div className="gridcard"><div className="gl">Uppercase</div><img src={res.uc} alt="uppercase" /></div>
960
+ <div className="gridcard"><div className="gl">Lowercase</div><img src={res.lc} alt="lowercase" /></div>
961
+ <div className="gridcard"><div className="gl">Punctuation</div><img src={res.punc} alt="punctuation" /></div>
962
+ </div>
963
+ </React.Fragment>
964
+ ) : (
965
+ <div className="placeholder">Output appears here</div>
966
+ )}
967
+ </section>
968
+ </div>
969
+
970
+ <div className="foot">
971
+ <span>Typotopia — generative type foundry</span>
972
+ <span>FLUX.2-klein · fontTools · potrace</span>
973
+ </div>
974
+ </div>
975
+ );
976
  }
977
 
978
+ ReactDOM.createRoot(document.getElementById("root")).render(<App />);
979
+ </script>
980
+ </body>
981
+ </html>
982
  """
983
 
984
+ FRONTEND_HTML = (
985
+ FRONTEND_HTML
986
+ .replace("__TITLE_FAMILY__", TITLE_FAMILY)
987
+ .replace("/*__TITLE_FONT_FACE__*/", TITLE_FONT_FACE)
988
+ )
989
+
990
+
991
+ @app.get("/", response_class=HTMLResponse)
992
+ async def homepage():
993
+ return FRONTEND_HTML
994
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
995
 
996
+ app.launch(show_error=True)