#!/usr/bin/env python3 """ normaere — Middle High German text normalizer. Original design: vertical workflow. 1. Input textarea at top (wide, line-numbered) 2. Control bar with options + Normalize button 3. Side-by-side numbered comparison table as output """ import argparse import base64 import html as html_mod import json import os import sys from pathlib import Path from threading import Lock from typing import Optional # ZeroGPU: `spaces` MUST be imported before torch (which is loaded lazily via # src.inference) so that ZeroGPU's CUDA patches are active before any CUDA call. # Do not set SPACES=false or other env hacks — they disable ZeroGPU integration. import spaces import gradio as gr # --------------------------------------------------------------------------- # Lazy-loaded normalizer # --------------------------------------------------------------------------- _normalizer = None _lock = Lock() def _get_normalizer(model_path: str, config_path: Optional[str] = None): global _normalizer if _normalizer is not None: return _normalizer with _lock: if _normalizer is not None: return _normalizer from src.inference import MHGNormalizer _normalizer = MHGNormalizer(model_path, config_path, defer_gpu=True) return _normalizer # --------------------------------------------------------------------------- # Normalization # --------------------------------------------------------------------------- @spaces.GPU(duration=120) def run_normalize( input_text: str, preserve_punctuation: bool, preserve_capitalization: bool, attach_en_proclitic: bool, lenition_t_after_n: bool, lenition_t_after_l: bool, niet_to_niht: bool, common_apocopes: bool, model_path: str, config_path: str, ): """Normalize text. Returns (raw_input, raw_output) or (input, error_msg).""" if not input_text or not input_text.strip(): return "", "" # Enforce the word limit BEFORE touching the GPU so oversized inputs never # consume ZeroGPU quota. Truncate to the first _MAX_WORDS words while # preserving line breaks (the UI also auto-caps; this is the safety net). wc = len(input_text.split()) if wc > _MAX_WORDS: kept_lines = [] running = 0 for line in input_text.split("\n"): line_words = line.split() if running + len(line_words) > _MAX_WORDS: # Partial last line: take only the words that fit remaining = _MAX_WORDS - running if remaining > 0: kept_lines.append(" ".join(line_words[:remaining])) break kept_lines.append(line) running += len(line_words) input_text = "\n".join(kept_lines) config = Path(config_path) if config_path else None norm = _get_normalizer(model_path, str(config) if config else None) norm._ensure_on_device() try: result = norm.normalize_multiline_text( input_text, preserve_punctuation=preserve_punctuation, preserve_capitalization=preserve_capitalization, attach_en_proclitic=attach_en_proclitic, lenition_t_after_n=lenition_t_after_n, lenition_t_after_l=lenition_t_after_l, niet_to_niht=niet_to_niht, common_apocopes=common_apocopes, ) return input_text, result except Exception as exc: return input_text, f"⚠ Error: {exc}" # --------------------------------------------------------------------------- # Input size limit # --------------------------------------------------------------------------- # ZeroGPU grants a limited daily GPU quota. To prevent a single large request # from consuming it (or timing out the @spaces.GPU call), we cap the input at # _MAX_WORDS. This is enforced server-side in run_normalize and surfaced in # the UI via a live word counter. _MAX_WORDS = 4000 def load_file(file_obj): if file_obj is None: return "" try: return file_obj.decode("utf-8") except UnicodeDecodeError: return file_obj.decode("latin-1") # --------------------------------------------------------------------------- # Comparison table builder # --------------------------------------------------------------------------- def build_table(input_text: str, output_text: str) -> str: """Build a numbered 3-column HTML table.""" if not input_text and not output_text: return "" in_lines = input_text.split("\n") out_lines = output_text.split("\n") n = max(len(in_lines), len(out_lines)) rows = [] for i in range(n): il = in_lines[i] if i < len(in_lines) else "" ol = out_lines[i] if i < len(out_lines) else "" if not il.strip() or not ol.strip(): continue rows.append( f"" f"{i + 1}" f"{html_mod.escape(il)}" f"{html_mod.escape(ol)}" f"" ) if not rows: return '
Nothing to show. Enter some text and press Normalize.
' return ( "
" "" f"{''.join(rows)}
#InputNormalized
" ) # --------------------------------------------------------------------------- # GPU / Device detection # --------------------------------------------------------------------------- def _detect_device() -> str: """Detect and return a short GPU/device badge label.""" try: import torch if torch.cuda.is_available(): try: name = torch.cuda.get_device_name(0) mem = round(torch.cuda.get_device_properties(0).total_memory / (1024 ** 3), 1) if torch.version.hip: return f"AMD ROCm · {name} · {mem} GB" return f"CUDA · {name} · {mem} GB" except Exception: # ZeroGPU: CUDA reports available but device queries can fail # outside an active @spaces.GPU context. Report ZeroGPU status. return "ZeroGPU" if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): try: import subprocess as _sp chip = _sp.check_output(["sysctl", "-n", "machdep.cpu.brand_string"], text=True).strip() if chip: return f"MPS · {chip}" except Exception: pass return "MPS · Apple Silicon" except Exception: pass return "CPU" # --------------------------------------------------------------------------- # CSS — dark scholarly aesthetic # --------------------------------------------------------------------------- _CSS = """ :root { --bg: #f5f3f0; --surface: #ffffff; --surface2: #f0edf7; --border: #e2dde8; --text: #140531; --text2: #3d2a5c; --text3: #9ca8c0; --accent: #008cd0; --accent2: #00b0ba; --gold: #e0b71c; --mauve: #b885ad; --red: #d63031; --mono: "IBM Plex Mono", "Cascadia Code", "JetBrains Mono", monospace; --sans: -apple-system, "Segoe UI", Inter, sans-serif; } body { background: var(--bg) !important; overflow-x: hidden !important; } .gradio-container, .main { max-width: 100% !important; padding: 0 !important; overflow-x: hidden !important; box-sizing: border-box !important; background: var(--bg) !important; } .main > .wrap { max-width: 100% !important; overflow-x: hidden !important; box-sizing: border-box !important; background: var(--bg) !important; padding-left: 1rem !important; padding-right: 1rem !important; } .gradio-container *, .gradio-container .gr-row, .gradio-container .gr-column, .gradio-container .gr-box { box-sizing: border-box !important; max-width: 100% !important; } /* Force light background on all Gradio container layers */ .gradio-container, .gradio-container .gr-row, .gradio-container .gr-column, .gradio-container .gr-box, .gradio-container .tabs, .gradio-container .tabitem, .main, .main > .wrap { background: var(--bg) !important; } footer { display: none !important; } /* ---- Header ---- */ .app-header { display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; padding: 2rem 2rem 1.5rem !important; background: var(--bg); border-bottom: none; text-align: center !important; width: 100% !important; } /* Collapse space between heading and subheading in brand */ .brand { gap: 0 !important; display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; width: 100% !important; } .brand > div, .brand > .gr-row > div { margin: 0 !important; padding: 0 !important; gap: 0 !important; text-align: center !important; align-items: center !important; } /* Remove margin/padding from Gradio markdown wrappers inside brand */ .brand .prose, .brand .markdown-prose, .brand .markdown, .brand > div > .prose, .brand > div > .markdown-prose, .brand > div > .markdown { margin: 0 !important; padding: 0 !important; gap: 0 !important; text-align: center !important; } .brand h1, .brand .prose h1, .brand .markdown-prose h1, .brand .markdown h1, .brand [data-testid="md-heading"] { font-size: 2.8rem !important; font-weight: 800 !important; color: #140531 !important; margin: 0 !important; text-align: center !important; letter-spacing: -0.03em !important; display: block !important; } /* Ensure no child element overrides the color */ .brand h1 *, .brand .prose h1 *, .brand .markdown-prose h1 *, .brand .markdown h1 * { color: #140531 !important; } .brand p, .brand p * { font-size: 1.15rem; color: #6b7a94 !important; margin: 0 !important; } /* ---- Input area ---- */ .input-wrap { margin: 0.5rem 0 0; background: var(--surface); border: 1px solid var(--border) !important; border-radius: 10px; overflow: hidden; max-width: 100% !important; gap: 0 !important; position: relative !important; } .input-wrap > .gr-column { gap: 0 !important; } /* ---- Loading overlay ---- */ #loadingOverlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 99999; background: rgba(245, 243, 240, 0.92); } #loadingOverlay.active { display: block !important; } #loadingOverlay .spinner { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 52px; height: 52px; border: 5px solid #e2dde8; border-top-color: #008cd0; border-radius: 50%; animation: spin 0.7s linear infinite; } @keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } } /* ---- Gradio loading indicator color ---- */ .icon-loading path, .icon-spin-loading path, svg.icon-loading path, svg.icon-spin-loading path, [data-testid="icon-loading"] path { fill: #e0b71c !important; } .input-actions { display: flex; gap: 0.75rem; font-size: 0.82rem; } .input-actions button, .input-actions a { background: none !important; border: none !important; color: var(--text3) !important; cursor: pointer; padding: 0.2rem 0.4rem !important; font-size: 0.82rem !important; transition: color 0.15s; } .input-actions button:hover, .input-actions a:hover { color: var(--accent) !important; } #input-text { border: none !important; outline: none !important; box-shadow: none !important; margin-top: 0 !important; } #input-text textarea { border: none !important; border-radius: 0 !important; background: var(--surface) !important; color: var(--text) !important; font-family: var(--mono) !important; font-size: 0.88rem !important; line-height: 1.65 !important; padding: 0rem 1rem 1rem !important; box-shadow: none !important; outline: none !important; } #input-text textarea:focus, #input-text textarea:focus-visible { border: none !important; box-shadow: none !important; outline: none !important; } #input-text textarea:focus { box-shadow: none !important; } #input-text textarea::placeholder { color: var(--text3) !important; } /* Darken Gradio label above input textarea */ #input-text label { color: var(--text2) !important; font-weight: 600 !important; } /* Word/char/line counter above the input textarea */ .input-meta { font-size: 0.92rem !important; color: #00b0ba; font-family: var(--sans) !important; font-weight: 500; padding: 0 0.25rem 0.1rem !important; margin: 0 !important; text-align: right; transition: color 0.15s ease; } /* Zero the gap between the word counter and the input textbox. Gradio wraps each component in its own div with default margins; we must zero all of them, not just the column gap. */ .input-col { gap: 0 !important; } .input-col > * { margin: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; } .input-meta { line-height: 1.1 !important; } /* Output textbox */ #output-text { border: none !important; outline: none !important; box-shadow: none !important; } #output-text textarea { border: none !important; border-radius: 0 !important; background: var(--surface) !important; color: var(--text) !important; font-family: var(--mono) !important; font-size: 0.88rem !important; line-height: 1.65 !important; padding: 1rem !important; box-shadow: none !important; outline: none !important; } #output-text textarea:focus, #output-text textarea:focus-visible { border: none !important; box-shadow: none !important; outline: none !important; } #output-text textarea::placeholder { color: var(--text3) !important; } /* Darken Gradio label above output textarea */ #output-text label { color: var(--text2) !important; font-weight: 600 !important; } /* ---- Layout: sidebar + main ---- */ .layout-row { gap: 0.75rem !important; margin: 0.75rem 0 0 !important; align-items: flex-start !important; } /* ---- Footer ---- */ .app-footer { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.8rem; padding: 2rem 1rem 1rem; margin-top: 1rem; } .app-footer span { font-size: 0.85rem; color: #999; } .app-footer a { display: inline-block; text-decoration: none; } .footer-logo { height: 128px !important; width: auto !important; max-height: 128px !important; max-width: 400px !important; display: block !important; } /* ---- Sidebar ---- */ .sidebar-wrap { background: var(--surface) !important; border: 1px solid var(--border); border-radius: 10px; padding: 0.8rem 0.7rem !important; gap: 0 !important; } /* Collapse spacing between toggle components in sidebar */ .sidebar-wrap > div > div > div { margin: 0 !important; padding: 0 !important; gap: 0 !important; } .sidebar-title { font-size: 0.72rem !important; font-weight: 700 !important; text-transform: uppercase !important; letter-spacing: 0.08em !important; color: #140531 !important; margin: 0 0 0.3rem 0 !important; padding: 0 !important; } /* ---- Device indicator (dot + label under subtitle) ---- */ .device-indicator { display: inline-flex !important; align-items: center !important; gap: 0.35rem !important; font-size: 0.72rem !important; font-weight: 500 !important; font-family: var(--sans) !important; color: var(--text3) !important; margin: 0 !important; padding: 0 !important; } .device-dot { display: inline-block !important; width: 8px !important; height: 8px !important; border-radius: 50% !important; flex-shrink: 0 !important; } /* ---- Toggle Rows (real switches) ---- */ .toggle-row { display: flex !important; align-items: center !important; gap: 0.5rem !important; padding: 0.3rem 0.4rem !important; background: transparent !important; border: none !important; border-radius: 6px !important; cursor: pointer; user-select: none; margin-bottom: 0.3rem !important; } .toggle-spacer { height: 0.5rem !important; width: 100% !important; display: block !important; margin: 0 !important; padding: 0 !important; } .toggle-row:hover { background: var(--surface2) !important; } .toggle-label { font-size: 0.82rem !important; color: #140531 !important; line-height: 1.2 !important; } .toggle-label i { color: #140531 !important; } /* Tooltip question mark */ .tooltip-q { display: inline-flex !important; align-items: center; justify-content: center; width: 14px !important; height: 14px !important; border-radius: 50%; background: #94a3b8; color: white; font-size: 10px; font-weight: 700; line-height: 1; cursor: help; position: relative; flex-shrink: 0; } .tooltip-q > .tooltip-content { display: none; position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: #1e293b; color: #f8fafc; padding-top: 0.6rem !important; padding-bottom: 0.6rem !important; padding-left: 1.5rem !important; padding-right: 1.5rem !important; border-radius: 6px; font-size: 0.75rem; font-weight: 400; white-space: normal; min-width: 180px; width: auto; z-index: 200; pointer-events: none; line-height: 1.5; } .tooltip-q:hover > .tooltip-content { display: block; } /* Toggle switch track */ .toggle-switch { width: 36px !important; height: 20px !important; border-radius: 20px !important; background: #ccc !important; transition: background 0.2s ease !important; flex-shrink: 0 !important; position: relative !important; display: block !important; cursor: pointer; } .toggle-switch.active { background: var(--accent) !important; } /* Toggle thumb */ .toggle-thumb { width: 16px !important; height: 16px !important; background: #fff !important; border-radius: 50% !important; position: absolute !important; top: 2px !important; left: 2px !important; transition: transform 0.2s ease !important; box-shadow: 0 1px 3px rgba(0,0,0,0.2) !important; } .toggle-switch.active .toggle-thumb { transform: translateX(16px) !important; } /* Ensure HTML container in sidebar is transparent */ .sidebar-wrap .prose, .sidebar-wrap .markdown, .sidebar-wrap div[role="document"] { background: transparent !important; } .sidebar-wrap .prose p, .sidebar-wrap .prose * { margin: 0 !important; padding: 0 !important; } /* ---- Control bar (between header and content) ---- */ .control-bar { display: flex; align-items: center; gap: 0.4rem; margin: 0.5rem 0 0; padding: 0.4rem; flex-wrap: wrap; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; } .ctrl-btn { padding: 0.35rem 0.7rem !important; font-size: 0.8rem !important; font-weight: 600 !important; border-radius: 8px !important; cursor: pointer; white-space: nowrap !important; line-height: 1.4 !important; } .ctrl-btn.normalize { background: linear-gradient(135deg, var(--accent), var(--accent2)) !important; color: white !important; border: none !important; } .ctrl-btn.normalize:hover { opacity: 0.9 !important; } .ctrl-btn.secondary { background: var(--surface2) !important; color: var(--text2) !important; border: 1px solid var(--border) !important; } .ctrl-btn.secondary:hover { background: var(--border) !important; } .ctrl-btn.dl-btn { background: #e0b71c !important; color: #140531 !important; border: none !important; font-weight: 700 !important; } .ctrl-btn.dl-btn:hover { opacity: 0.85 !important; } /* ---- Output ---- */ .output-wrap { margin: 0.75rem 0 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; max-width: 100% !important; } .output-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background: var(--surface2); border-bottom: 1px solid var(--border); } .output-toolbar label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent2); } .output-actions { display: flex; gap: 0.75rem; font-size: 0.82rem; } .output-actions button { background: none !important; border: none !important; color: var(--text3) !important; cursor: pointer; padding: 0.2rem 0.4rem !important; transition: color 0.15s; } .output-actions button:hover { color: var(--accent2) !important; } /* Comparison table */ .table-wrap { overflow-x: auto; padding: 0; } .table-wrap table { width: 100%; border-collapse: collapse; } .table-wrap thead th { position: sticky; top: 0; background: var(--surface2); padding: 0.6rem 1rem; font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #140531 !important; border-bottom: 1px solid var(--border); } .table-wrap tbody tr { border-bottom: 1px solid var(--border); } .table-wrap tbody tr:last-child { border-bottom: none; } .table-wrap tbody tr:hover { background: var(--surface2); } .table-wrap tbody td { padding: 0.4rem 1rem; font-family: var(--mono); font-size: 0.84rem; line-height: 1.6; vertical-align: top; white-space: pre-wrap; word-break: break-word; } .table-wrap .ln { text-align: center; color: #140531 !important; width: 45px; font-family: var(--sans); font-size: 0.75rem; user-select: none; white-space: nowrap !important; } .table-wrap .src { color: #140531 !important; width: 50%; user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; outline: none; } .table-wrap .src:focus { background: rgba(20, 5, 49, 0.04); box-shadow: inset 0 0 0 2px rgba(20, 5, 49, 0.15); } .table-wrap .dst { color: #140531 !important; font-weight: 500; width: 50%; user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; outline: none; } .table-wrap .dst:focus { background: rgba(20, 5, 49, 0.04); box-shadow: inset 0 0 0 2px rgba(20, 5, 49, 0.15); } .empty-state { text-align: center; padding: 3rem 2rem; color: var(--text3); font-size: 0.9rem; } /* Error output */ .output-wrap .error { color: var(--red); padding: 1rem; } /* ---- Diff highlights ---- */ .diff-highlight { background: #efe9a0 !important; color: #140531 !important; border-radius: 2px; padding: 0 1px; } /* Hide the revert button column (triggered programmatically) */ .revert-col { display: none !important; } /* Responsive */ @media (max-width: 768px) { .input-wrap, .control-bar, .output-wrap { margin-left: 0.75rem; margin-right: 0.75rem; } .app-header { padding: 1rem; } } """ # --------------------------------------------------------------------------- # JS helpers # --------------------------------------------------------------------------- _HEAD_JS = r""" """ # --------------------------------------------------------------------------- # Build UI # --------------------------------------------------------------------------- def build_app(model_path: str, config_path: str): with gr.Blocks(title="normaere") as demo: # ---- Header ---- with gr.Row(elem_classes=["app-header"]): with gr.Column(elem_classes=["brand"]): gr.Markdown("# **normære (beta)**") gr.Markdown("ich wil die krümbẹ an allen orten slihten — Jüngerer Titurel 20,3") device_label = _detect_device() has_gpu = not device_label == "CPU" dot_color = "#00b0ba" if has_gpu else "#999" gr.HTML(f'{device_label}') # ---- Control bar ---- with gr.Row(elem_classes=["control-bar"]): btn_normalize = gr.Button("⚙ Normalize", elem_classes=["ctrl-btn", "normalize"], variant="primary", size="sm") btn_clear_all = gr.Button("✕ Clear Input", elem_classes=["ctrl-btn", "secondary"], size="sm") btn_upload = gr.Button("📄 Upload .txt", elem_classes=["ctrl-btn", "secondary"], size="sm", elem_id="uploadBtn") btn_copy = gr.Button("📋 Copy Output", elem_classes=["ctrl-btn", "secondary"], size="sm", elem_id="copyBtn") btn_dl = gr.Button("⬇ Download .txt", elem_classes=["ctrl-btn", "dl-btn"], size="sm") # ---- Layout: Sidebar + Main ---- with gr.Row(elem_classes=["layout-row"]): # Sidebar (post-processing options) with gr.Column(scale=1, elem_classes=["sidebar-wrap"]): gr.HTML(''' Post-processing
Remove Punctuation
Convert to Lowercase
Attach en-Proclitic
n-Lenition? süntæresündære
l-Lenition? soltesolde
niet, niutniht
Common Apocopes? vilevil
 vorevor
 wilewil
 wolewol
Visualization
Highlight Changes
''') # Main content (input card → replaced by output card after normalize) with gr.Column(scale=4): with gr.Column(elem_classes=["input-wrap"]) as input_card: with gr.Row(): with gr.Column(elem_classes=["input-col"]): gr.HTML('
0 words · 0 lines · 0 chars (limit: 4,000 words)
') input_text = gr.Textbox( lines=12, max_lines=30, show_label=False, container=False, elem_id="input-text", placeholder="Paste or type Middle High German text here…", ) with gr.Column(elem_classes=["output-wrap"], visible=False) as output_card: comparison_output = gr.HTML(value="") # ---- State ---- mp_state = gr.State(model_path) cp_state = gr.State(config_path or "") # ---- Normalize (no post-processing — handled client-side) ---- def _do(text, *args): mp, cp = args[-2], args[-1] # Only normalize — post-processing happens client-side. # run_normalize is decorated with @spaces.GPU, so inference runs # on an allocated ZeroGPU (CUDA) device. A server-side word limit # (run_normalize) prevents oversized inputs from exhausting quota. raw_in, raw_out = run_normalize(text, True, True, False, False, False, False, False, mp, cp) if not raw_in and not raw_out: return "", gr.update(visible=False), gr.update(visible=True) if raw_out.startswith("⚠"): warning_html = ( f'
{html_mod.escape(raw_out)}
' ) return warning_html, gr.update(visible=True), gr.update(visible=False) out_lines = raw_out.split("\n") b64 = base64.b64encode(json.dumps(out_lines).encode()).decode() data_div = f'' table_html = build_table(raw_in, raw_out) + data_div return table_html, gr.update(visible=True), gr.update(visible=False) btn_normalize.click( fn=_do, inputs=[input_text, mp_state, cp_state], outputs=[comparison_output, output_card, input_card], ) # ---- Clear ---- def _clear(): return "", "", gr.update(visible=True), gr.update(visible=False) btn_clear_all.click(fn=_clear, outputs=[comparison_output, input_text, input_card, output_card]) # ---- Revert to input (triggered when user clears output) ---- with gr.Column(elem_classes=["revert-col"]): btn_revert = gr.Button(elem_id="revertBtn") def _revert(): return "", "", gr.update(visible=False), gr.update(visible=True) btn_revert.click( fn=_revert, outputs=[comparison_output, input_text, output_card, input_card], ) # ---- Show input without clearing (for file upload) ---- with gr.Column(elem_classes=["revert-col"]): btn_show_input = gr.Button(elem_id="showInputBtn") def _show_input_only(): return gr.update(visible=False), gr.update(visible=True) btn_show_input.click( fn=_show_input_only, outputs=[output_card, input_card], ) # ---- Copy / Download (JS) ---- btn_copy.click(js="window._copyOut()") btn_dl.click(js="window._dlOut()") # ---- Footer ---- logo_path = Path(__file__).parent / "static" / "logo.png" logo_src = "" if logo_path.exists(): import base64 as _b64 logo_data = logo_path.read_bytes() logo_src = f"data:image/png;base64,{_b64.b64encode(logo_data).decode()}" gr.HTML( f'' ) return demo # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def _ensure_model_downloaded(model_path: str) -> str: """If model_path is a Hub repo ID, download it to a local cache. On HF Spaces the model is not bundled, so we must download it at runtime. For local paths, returns the path unchanged. """ p = Path(model_path) if p.exists(): return str(p) # Treat as Hub repo ID and download from huggingface_hub import snapshot_download print(f"Model not found locally — downloading '{model_path}' from Hub …") cached = snapshot_download(repo_id=model_path, cache_dir=None) print(f" Downloaded to: {cached}") return cached def main(): # Respect HF Spaces $PORT env var (defaults to 7860) default_port = int(os.environ.get("PORT", 7860)) default_host = os.environ.get("HOST", "0.0.0.0") parser = argparse.ArgumentParser(description="normaere") parser.add_argument("--model_path", default=None) parser.add_argument("--config_path", default="config.yaml") parser.add_argument("--port", type=int, default=default_port) parser.add_argument("--host", default=default_host) parser.add_argument("--share", action="store_true") args = parser.parse_args() import yaml config_path = Path(args.config_path) if args.config_path else None model_path = args.model_path if model_path is None and config_path and config_path.exists(): try: with open(config_path, "r") as f: cfg = yaml.safe_load(f) model_path = cfg.get("inference", {}).get("model_path") except Exception: pass if model_path is None: model_path = "JonasHermann/normaere-model" model_path = _ensure_model_downloaded(model_path) cp = str(config_path) if config_path else None # Preload the model at startup (loads weights to CPU RAM as BF16). # The slow disk I/O + deserialization happens here, OUTSIDE the # ZeroGPU duration budget. The CPU→GPU transfer is deferred to # run_normalize (inside @spaces.GPU) via _ensure_on_device(). print("Preloading model to CPU …") _get_normalizer(model_path, cp) print("Model preloaded.\n") print(f"Starting normaere on http://{args.host}:{args.port}") print(f"Model: {model_path}\n") demo = build_app(model_path, cp) demo.launch( server_name=args.host, server_port=args.port, share=args.share, css=_CSS, head=_HEAD_JS, ) if __name__ == "__main__": main()