normaere / app.py
JonasHermann's picture
Upload folder using huggingface_hub
1541ef3 verified
Raw
History Blame Contribute Delete
58.1 kB
#!/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"<tr>"
f"<td class='ln'>{i + 1}</td>"
f"<td class='src' contenteditable='true'>{html_mod.escape(il)}</td>"
f"<td class='dst' contenteditable='true'>{html_mod.escape(ol)}</td>"
f"</tr>"
)
if not rows:
return '<div class="empty-state">Nothing to show. Enter some text and press Normalize.</div>'
return (
"<div class='table-wrap'><table>"
"<thead><tr><th>#</th><th>Input</th><th>Normalized</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table></div>"
)
# ---------------------------------------------------------------------------
# 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"""
<script>
// Word/char/line counter with ZeroGPU word-limit warning + auto-cap
(function() {
var MAX_WORDS = 4000;
var capping = false; // guard against re-entrant input events
function update() {
var ta = document.querySelector('#input-text textarea');
if (!ta) return;
var v = ta.value;
lastVal = v;
var words = v ? v.trim().split(/\s+/).filter(function(w){return w.length;}).length : 0;
// Auto-cap: truncate input at MAX_WORDS, preserving line breaks.
// Dispatch an input event so Gradio syncs the truncated value to the server.
if (words > MAX_WORDS) {
var keptLines = [];
var running = 0;
var allLines = v.split('\n');
for (var li = 0; li < allLines.length; li++) {
var lw = allLines[li].split(/\s+/).filter(function(w){return w.length;});
if (running + lw.length > MAX_WORDS) {
var remaining = MAX_WORDS - running;
if (remaining > 0) keptLines.push(lw.slice(0, remaining).join(' '));
break;
}
keptLines.push(allLines[li]);
running += lw.length;
}
capping = true;
ta.value = keptLines.join('\n');
ta.dispatchEvent(new Event('input', {bubbles: true}));
capping = false;
v = ta.value;
words = MAX_WORDS;
}
var chars = v.length;
var lines = v ? v.split('\n').length : 0;
var el = document.getElementById('metaCount');
if (!el) return;
el.textContent = words.toLocaleString() + ' words · ' + lines + ' lines · ' + chars.toLocaleString() + ' chars (limit: ' + MAX_WORDS.toLocaleString() + ' words)';
// Color: green ≤3000, orange 3001–3999, red at 4000
if (words >= MAX_WORDS) {
el.style.color = '#b885ad';
} else if (words > 3000) {
el.style.color = '#e0b71c';
} else {
el.style.color = '#00b0ba';
}
el.style.fontWeight = (words > 3000) ? '600' : '';
}
var tries = 0;
var lastVal = null;
function poll() {
var ta = document.querySelector('#input-text textarea');
if (ta) {
ta.addEventListener('input', function() { if (!capping) update(); });
update();
// Safety net: catch programmatic value changes (e.g. Clear button,
// file upload) that don't fire native 'input' events.
setInterval(function() {
var t = document.querySelector('#input-text textarea');
if (!t) return;
if (t.value !== lastVal) {
lastVal = t.value;
update();
}
}, 300);
return;
}
if (++tries < 30) setTimeout(poll, 200);
}
poll();
})();
// Copy output
window._copyOut = function() {
var rows = document.querySelectorAll('.table-wrap tbody tr');
if (!rows.length) return;
var lines = [];
rows.forEach(function(r) {
var cells = r.querySelectorAll('td');
if (cells.length >= 3) lines.push(cells[2].textContent);
});
var nl = String.fromCharCode(10);
navigator.clipboard.writeText(lines.join(nl)).then(function() {
var b = document.getElementById('copyBtn');
if (b) { b.textContent = '✓ copied'; setTimeout(function() { b.textContent = '📋 Copy Output'; }, 1200); }
});
};
// Download output
window._dlOut = function() {
var rows = document.querySelectorAll('.table-wrap tbody tr');
if (!rows.length) return;
var lines = [];
rows.forEach(function(r) {
var cells = r.querySelectorAll('td');
if (cells.length >= 3) lines.push(cells[2].textContent);
});
var nl = String.fromCharCode(10);
var blob = new Blob([lines.join(nl)], {type:'text/plain'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'normaere_output.txt';
a.click();
URL.revokeObjectURL(a.href);
};
// Watch output for empty content and revert to input
(function() {
var _reverting = false;
setInterval(function() {
if (_reverting) return;
var outputWrap = document.querySelector('.output-wrap');
if (!outputWrap || outputWrap.offsetParent === null) return;
var table = outputWrap.querySelector('.table-wrap');
if (!table) return;
var rows = table.querySelectorAll('tbody tr');
if (rows.length > 0) {
var allEmpty = true;
for (var i = 0; i < rows.length; i++) {
var src = rows[i].querySelector('td.src');
var dst = rows[i].querySelector('td.dst');
if (src && src.textContent.trim()) { allEmpty = false; break; }
if (dst && dst.textContent.trim()) { allEmpty = false; break; }
}
if (!allEmpty) return;
}
// Output table is empty (no rows or all cells emptied) — trigger revert
_reverting = true;
var revertBtn = document.getElementById('revertBtn');
if (revertBtn) revertBtn.click();
setTimeout(function() { _reverting = false; }, 2000);
}, 500);
})();
// Upload button — opens a native file picker
(function() {
var tries = 0;
function poll() {
var btn = document.getElementById('uploadBtn');
var textarea = document.querySelector('#input-text textarea');
if (btn && textarea) {
btn.addEventListener('click', function() {
var inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.txt';
inp.onchange = function() {
var f = inp.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function() {
textarea.value = reader.result;
textarea.dispatchEvent(new Event('input', {bubbles:true}));
// Show input card without clearing content
var showInputBtn = document.getElementById('showInputBtn');
if (showInputBtn) showInputBtn.click();
};
reader.readAsText(f, 'UTF-8');
};
inp.click();
});
return;
}
if (++tries < 30) setTimeout(poll, 200);
}
poll();
})();
// Loading overlay — inject, show on Normalize click, hide when output appears
(function() {
var tries = 0;
function init() {
var inputWrap = document.querySelector('.input-wrap');
var normalizeBtn = document.querySelector('.normalize');
if (!inputWrap || !normalizeBtn) {
if (++tries < 30) setTimeout(init, 200);
return;
}
// Inject overlay into body
var overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.innerHTML = '<div class="spinner"></div>';
document.body.appendChild(overlay);
// Show on normalize click
normalizeBtn.addEventListener('click', function() {
overlay.classList.add('active');
});
// Hide when output card appears
setInterval(function() {
var outputCard = document.querySelector('.output-wrap');
if (overlay.classList.contains('active')) {
if (outputCard && outputCard.offsetParent !== null) {
overlay.classList.remove('active');
}
}
}, 200);
}
init();
})();
// Toggle row click handlers
(function() {
var tries = 0;
function poll() {
var rows = document.querySelectorAll('.toggle-row');
if (rows.length) {
rows.forEach(function(row) {
var sw = row.querySelector('.toggle-switch');
if (!sw) return;
row.addEventListener('click', function() {
sw.classList.toggle('active');
if (window._applyPostProcessing) window._applyPostProcessing();
});
});
return;
}
if (++tries < 30) setTimeout(poll, 200);
}
poll();
})();
// Client-side post-processing
var _PUNCT = '.,;:!?()[]«»‹›\u201c\u201d\u201e\u0022\u201a\u2018\u2019\u2014\u2013<>';
function _isPunct(ch) { return _PUNCT.indexOf(ch) !== -1; }
// Strip all punctuation characters
function _applyRemovePunct(line) {
return line.split('.').join('')
.split(',').join('')
.split(';').join('')
.split(':').join('')
.split('!').join('')
.split('?').join('')
.split('"').join('')
.split("'").join('')
.split('(').join('')
.split(')').join('')
.split('[').join('')
.split(']').join('')
.split('{').join('')
.split('}').join('')
.split('-').join('')
.split('_').join('')
.split('\u2013').join('')
.split('\u2014').join('')
.split('\u00ab').join('')
.split('\u00bb').join('');
}
// Convert to lowercase
function _applyLowercase(line) {
return line.toLowerCase();
}
// MHG: Attach en-proclitic to following word ("en guot" \u2192 "enguot")
function _applyEnProclitic(line) {
var tokens = line.split(/\s+/);
var result = [];
for (var i = 0; i < tokens.length; i++) {
var tok = tokens[i];
if (tok.length === 2 && tok.toLowerCase() === 'en' && i + 1 < tokens.length) {
result.push(tok + tokens[i + 1]);
i++; // skip next token
} else {
result.push(tok);
}
}
return result.join(' ');
}
// MHG: Lenition nt \u2192 nd before a vowel
function _applyLenition(line) {
var vowels = 'aeiouyAEIOUY\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00e2\u00ea\u00ee\u00f4\u00fb\u00c2\u00ca\u00ce\u00d4\u00db\u0101\u0115\u012b\u014d\u016b\u0100\u0114\u012a\u014c\u016a\u00e5\u00e6\u0153\u00f8\u00c5\u00c6\u0152\u00d8';
var out = '';
for (var i = 0; i < line.length - 1; i++) {
var c = line[i], c2 = line[i + 1];
if ((c === 'n' && c2 === 't') || (c === 'N' && c2 === 'T')) {
var next = (i + 2 < line.length) ? line[i + 2] : '';
if (vowels.indexOf(next) !== -1) {
out += c + (c2 === 't' ? 'd' : 'D');
i++;
continue;
}
}
out += c;
}
out += line.slice(i);
return out;
}
// MHG: Lenition lt \u2192 ld before a vowel
function _applyLenitionL(line) {
var vowels = 'aeiouyAEIOUY\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00e2\u00ea\u00ee\u00f4\u00fb\u00c2\u00ca\u00ce\u00d4\u00db\u0101\u0115\u012b\u014d\u016b\u0100\u0114\u012a\u014c\u016a\u00e5\u00e6\u0153\u00f8\u00c5\u00c6\u0152\u00d8';
var out = '';
for (var i = 0; i < line.length - 1; i++) {
var c = line[i], c2 = line[i + 1];
if ((c === 'l' && c2 === 't') || (c === 'L' && c2 === 'T')) {
var next = (i + 2 < line.length) ? line[i + 2] : '';
if (vowels.indexOf(next) !== -1) {
out += c + (c2 === 't' ? 'd' : 'D');
i++;
continue;
}
}
out += c;
}
out += line.slice(i);
return out;
}
// MHG: niet / niut \u2192 niht (preserve case + trailing punctuation)
function _applyNiet(line) {
var out = '';
var i = 0;
while (i < line.length) {
var substr = line.slice(i).toLowerCase();
var word = '', j;
if (substr.indexOf('niet') === 0) { word = 'niet'; j = 4; }
else if (substr.indexOf('niut') === 0) { word = 'niut'; j = 4; }
else { out += line[i]; i++; continue; }
var start = i, end = i + j;
// allow trailing punctuation
while (end < line.length && _isPunct(line[end])) end++;
var afterOk = (end >= line.length || line[end] === ' ' || line[end] === '\n' || line[end] === '\t');
var beforeOk = (start === 0 || line[start - 1] === ' ' || line[start - 1] === '\n' || line[start - 1] === '\t');
if (beforeOk && afterOk) {
var trailing = line.slice(i + j, end);
var orig = line.slice(i, i + j);
var base = 'niht';
if (orig[0] === orig[0].toUpperCase()) {
base = base[0].toUpperCase() + base.slice(1);
}
var allUpper = (orig === orig.toUpperCase());
if (allUpper) base = base.toUpperCase();
out += base + trailing;
i = end;
} else {
out += line[i]; i++;
}
}
return out;
}
// MHG: Common apocopes vile\u2192vil, vore\u2192vor, wile\u2192wil, wole\u2192wol (preserve case + trailing punctuation)
function _applyApocopes(line) {
var REPL = { vile: 'vil', vore: 'vor', wile: 'wil', wole: 'wol' };
var out = '';
var i = 0;
while (i < line.length) {
var substr = line.slice(i).toLowerCase();
var word = '', j;
if (substr.indexOf('vile') === 0) { word = 'vile'; j = 4; }
else if (substr.indexOf('vore') === 0) { word = 'vore'; j = 4; }
else if (substr.indexOf('wile') === 0) { word = 'wile'; j = 4; }
else if (substr.indexOf('wole') === 0) { word = 'wole'; j = 4; }
else { out += line[i]; i++; continue; }
var start = i, end = i + j;
while (end < line.length && _isPunct(line[end])) end++;
var afterOk = (end >= line.length || line[end] === ' ' || line[end] === '\n' || line[end] === '\t');
var beforeOk = (start === 0 || line[start - 1] === ' ' || line[start - 1] === '\n' || line[start - 1] === '\t');
if (beforeOk && afterOk) {
var trailing = line.slice(i + j, end);
var orig = line.slice(i, i + j);
var base = REPL[word];
if (orig[0] === orig[0].toUpperCase()) {
base = base[0].toUpperCase() + base.slice(1);
}
out += base + trailing;
i = end;
} else {
out += line[i]; i++;
}
}
return out;
}
// Character-level diff highlighting using simple LCS
function _highlightDiffs(original, target) {
var origLen = original.length, tgtLen = target.length;
var cap = 5000;
var oLen = Math.min(origLen, cap), tLen = Math.min(tgtLen, cap);
// Build LCS table
var dp = [];
for (var r = 0; r <= oLen; r++) {
dp[r] = [0];
}
for (var c = 1; c <= tLen; c++) {
dp[0][c] = 0;
}
for (var r = 1; r <= oLen; r++) {
for (var c = 1; c <= tLen; c++) {
if (original[r - 1] === target[c - 1])
dp[r][c] = dp[r - 1][c - 1] + 1;
else
dp[r][c] = Math.max(dp[r - 1][c], dp[r][c - 1]);
}
}
// Back-track to find matching positions in target
var matchSet = {};
var r = oLen, c = tLen;
while (r > 0 && c > 0) {
if (original[r - 1] === target[c - 1]) {
matchSet[c - 1] = true;
r--; c--;
} else if (dp[r - 1][c] >= dp[r][c - 1]) {
r--;
} else {
c--;
}
}
// Build highlighted HTML
var result = '';
for (var k = 0; k < tgtLen; k++) {
if (matchSet.hasOwnProperty(k)) {
result += target[k];
} else {
result += '<mark class="diff-highlight">' + target[k] + '</mark>';
}
}
return result;
}
// Track user edits per row index so toggles can still revert.
if (typeof window._mhgUserEdits === 'undefined') window._mhgUserEdits = {};
function _recordUserEdit(idx) {
var dstCells = document.querySelectorAll('.table-wrap tbody tr td.dst');
if (dstCells[idx]) window._mhgUserEdits[idx] = dstCells[idx].textContent || '';
}
function _clearUserEdits() {
window._mhgUserEdits = {};
}
// Delegate input events to capture user edits in dst cells.
(function() {
document.addEventListener('input', function(e) {
var cell = e.target.closest('td.dst');
if (!cell) return;
var tr = cell.closest('tr');
if (!tr) return;
var allRows = Array.from(tr.parentElement.querySelectorAll('tr'));
var idx = allRows.indexOf(tr);
if (idx >= 0) _recordUserEdit(idx);
});
})();
window._applyPostProcessing = function() {
// Re-read raw baseline
var el = document.getElementById('rawOutData');
if (!el) return;
var b64 = el.getAttribute('data-lines');
if (!b64) return;
var decoded = atob(b64);
var lines = JSON.parse(decoded);
if (!lines.length) return;
// Overlay user edits: if user edited a row, start from that instead of baseline
var edits = window._mhgUserEdits;
if (edits) {
for (var idx in edits) {
if (edits.hasOwnProperty(idx)) {
var i = parseInt(idx);
if (i >= 0 && i < lines.length) lines[i] = edits[idx];
}
}
}
lines = lines.slice(0); // copy
var getToggle = function(name) {
var row = document.querySelector('.toggle-row[data-toggle="' + name + '"]');
if (!row) return false;
return row.querySelector('.toggle-switch').classList.contains('active');
};
var removePunct = getToggle('punct');
var convertLower = getToggle('cap');
var doEn = getToggle('en');
var doLenition = getToggle('lenition');
var doLenitionL = getToggle('lenitionl');
var doNiet = getToggle('niet');
var doApocopes = getToggle('apocopes');
var doHighlight = getToggle('highlight');
for (var i = 0; i < lines.length; i++) {
if (removePunct) lines[i] = _applyRemovePunct(lines[i]);
if (convertLower) lines[i] = _applyLowercase(lines[i]);
if (doApocopes) lines[i] = _applyApocopes(lines[i]);
if (doEn) lines[i] = _applyEnProclitic(lines[i]);
if (doLenition) lines[i] = _applyLenition(lines[i]);
if (doLenitionL) lines[i] = _applyLenitionL(lines[i]);
if (doNiet) lines[i] = _applyNiet(lines[i]);
}
// Update table dst column
var dstCells = document.querySelectorAll('.table-wrap tbody tr td.dst');
var srcCells = document.querySelectorAll('.table-wrap tbody tr td.src');
for (var j = 0; j < dstCells.length && j < lines.length; j++) {
if (doHighlight) {
var srcText = srcCells[j] ? srcCells[j].textContent : '';
dstCells[j].innerHTML = _highlightDiffs(srcText, lines[j]);
} else {
dstCells[j].textContent = lines[j];
}
}
};
// Watch for output updates and re-apply post-processing
(function() {
var prev = '';
setInterval(function() {
var el = document.getElementById('rawOutData');
if (!el) return;
var cur = el.getAttribute('data-lines');
if (cur && cur !== prev) {
prev = cur;
_clearUserEdits();
window._applyPostProcessing();
}
}, 300);
})();
// Force sidebar gap between toggle rows
(function() {
function setSidebarGap() {
var el = document.querySelector('.sidebar-wrap');
if (el) {
el.style.gap = '0.2rem';
}
}
setSidebarGap();
setInterval(setSidebarGap, 500);
})();
// Sync edited input cells back to textarea before normalize
(function() {
var tries = 0;
function poll() {
var normalizeBtn = document.querySelector('.normalize');
var ta = document.querySelector('#input-text textarea');
if (normalizeBtn && ta) {
normalizeBtn.addEventListener('click', function() {
var rows = document.querySelectorAll('.table-wrap tbody tr');
if (rows.length) {
var lines = [];
rows.forEach(function(r) {
var src = r.querySelector('td.src');
if (src) lines.push(src.textContent);
});
if (lines.length) {
ta.value = lines.join('\n');
ta.dispatchEvent(new Event('input', {bubbles:true}));
}
}
}, true); // capture phase = fires before Gradio handler
return;
}
if (++tries < 30) setTimeout(poll, 200);
}
poll();
})();
// Auto-insert new row on Enter/Shift+Enter in editable table cells
(function() {
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' && e.key !== 'Shift+Enter') return;
if (!e.target || !e.target.classList.contains('src') && !e.target.classList.contains('dst')) return;
if (!e.target.getAttribute('contenteditable')) return;
e.preventDefault();
var cell = e.target;
var row = cell.closest('tr');
if (!row) return;
var tbody = row.parentNode;
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.children);
var idx = rows.indexOf(row);
var cells = row.querySelectorAll('td');
var ln = parseInt(cells[0].textContent) + 1 || (idx + 2);
// Get selection range to split text at cursor
var sel = window.getSelection();
var range = sel.getRangeAt(0);
var textBefore = '';
var textAfter = '';
if (range.startContainer === cell || cell.contains(range.startContainer)) {
var splitRange = document.createRange();
splitRange.selectNodeContents(cell);
splitRange.setEnd(range.startContainer, range.startOffset);
textBefore = splitRange.toString();
textAfter = cell.textContent.substring(textBefore.length);
} else {
textBefore = cell.textContent;
textAfter = '';
}
// Create new row with empty cells
var newRow = document.createElement('tr');
newRow.innerHTML = '<td class="ln">' + ln + '</td><td class="src" contenteditable="true"></td><td class="dst" contenteditable="true"></td>';
tbody.insertBefore(newRow, row.nextSibling);
// Update current cell to text before cursor
cell.textContent = textBefore;
// Set new cell to text after cursor
var sameClass = cell.classList.contains('src') ? 'src' : 'dst';
var newCell = newRow.querySelector('td.' + sameClass);
if (newCell) newCell.textContent = textAfter;
// Focus the new cell
if (newCell) newCell.focus();
});
})();
</script>
"""
# ---------------------------------------------------------------------------
# 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("<i>ich wil die krümbẹ an allen orten slihten</i> — 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'<span class="device-indicator"><span class="device-dot" style="background:{dot_color}"></span>{device_label}</span>')
# ---- 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('''
<span class="sidebar-title">Post-processing</span>
<div class="toggle-row" data-toggle="punct"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Remove Punctuation</span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="cap"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Convert to Lowercase</span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="en"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Attach <i>en</i>-Proclitic</span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="lenition"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>n</i>-Lenition</span><span class="tooltip-q">?<span class="tooltip-content">&nbsp;<i>süntære</i> → <i>sündære</i></span></span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="lenitionl"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>l</i>-Lenition</span><span class="tooltip-q">?<span class="tooltip-content">&nbsp;<i>solte</i> → <i>solde</i></span></span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="niet"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label"><i>niet</i>, <i>niut</i> → <i>niht</i></span></div>
<div style="height:4px"></div>
<div class="toggle-row" data-toggle="apocopes"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Common Apocopes</span><span class="tooltip-q">?<span class="tooltip-content">&nbsp;<i>vile</i> → <i>vil</i><br>&nbsp;<i>vore</i> → <i>vor</i><br>&nbsp;<i>wile</i> → <i>wil</i><br>&nbsp;<i>wole</i> → <i>wol</i></span></span></div>
<div style="height:1rem"></div>
<span class="sidebar-title">Visualization</span>
<div class="toggle-row" data-toggle="highlight"><span class="toggle-switch"><span class="toggle-thumb"></span></span><span class="toggle-label">Highlight Changes</span></div>
''')
# 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('<div id="metaCount" class="input-meta">0 words · 0 lines · 0 chars (limit: 4,000 words)</div>')
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'<div style="color:#b885ad;font-size:0.95rem;font-weight:600;'
f'padding:2rem 1rem;line-height:1.6;">{html_mod.escape(raw_out)}</div>'
)
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'<div id="rawOutData" style="display:none" data-lines="{b64}"></div>'
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'<div class="app-footer" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.8rem;padding:2rem 1rem 1rem;margin-top:1rem;text-align:center;">'
f'<span style="font-size:0.85rem;color:#999;">Brought to you by</span>'
f'<a href="https://uni-freiburg.de/inqdialog/" target="_blank" style="display:inline-block;text-decoration:none;">'
f'<img src="{logo_src}" alt="inqdialog" class="footer-logo" style="height:96px;width:auto;max-height:96px;max-width:300px;display:block;" />'
f'</a></div>'
)
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()