#!/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 (
""
"| # | Input | Normalized |
"
f"{''.join(rows)}
"
)
# ---------------------------------------------------------------------------
# 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('''
Remove Punctuation
Convert to Lowercase
Attach en-Proclitic
n-Lenition? süntære → sündære
l-Lenition? solte → solde
niet, niut → niht
Common Apocopes? vile → vil
vore → vor
wile → wil
wole → wol
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()