Coconuttttt commited on
Commit
5854274
ยท
verified ยท
1 Parent(s): 9aed0be

Restore full UI + add page range selection

Browse files
Files changed (1) hide show
  1. app_gradio.py +659 -170
app_gradio.py CHANGED
@@ -1,170 +1,659 @@
1
- """
2
- app_gradio.py
3
-
4
- Gradio ์›น UI โ€” ์•…๋ณด(PDF/PNG/JPG) ์—…๋กœ๋“œ โ†’ MML ๋ณ€ํ™˜ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
5
-
6
- ์‹คํ–‰:
7
- python app_gradio.py
8
-
9
- ์™ธ๋ถ€ ์ ‘์†:
10
- python app_gradio.py --share
11
-
12
- Hugging Face Spaces ๋ฐฐํฌ:
13
- Dockerfile์—์„œ ENTRYPOINT๋กœ ์‹คํ–‰๋จ
14
- """
15
-
16
- import argparse
17
- import datetime
18
- import os
19
- import tempfile
20
- from pathlib import Path
21
-
22
- import gradio as gr
23
-
24
- from core.convert_pipeline import run_score_pipeline
25
-
26
-
27
- SUPPORTED_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg"]
28
- MAX_PDF_PAGES = int(os.environ.get("MAX_PDF_PAGES", "5"))
29
-
30
- _PREPROCESS_MAP = {"์—†์Œ": "none", "Otsu": "otsu", "Adaptive": "adaptive", "๋Œ€๋น„๊ฐ•ํ™”": "contrast"}
31
- _UPSCALE_MAP = {
32
- "์—†์Œ": "none",
33
- "PIL 2ร—": "pil_2", "PIL 3ร—": "pil_3",
34
- }
35
-
36
-
37
- def _check_pdf_pages(file_path: str) -> str | None:
38
- """PDF ํŽ˜์ด์ง€ ์ˆ˜ ์ œํ•œ ๊ฒ€์‚ฌ. ์ดˆ๊ณผ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜."""
39
- if Path(file_path).suffix.lower() != ".pdf":
40
- return None
41
- try:
42
- import fitz
43
- doc = fitz.open(file_path)
44
- count = doc.page_count
45
- doc.close()
46
- if count > MAX_PDF_PAGES:
47
- return f"PDF๊ฐ€ {count}ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ์ตœ๋Œ€ {MAX_PDF_PAGES}ํŽ˜์ด์ง€๊นŒ์ง€ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค."
48
- except Exception:
49
- pass
50
- return None
51
-
52
-
53
- def convert(file_path: str, preprocess: str, dpi: int, upscale: str, page_start: int, page_end: int, progress=gr.Progress()) -> tuple[str, str | None, list, list, str]:
54
- if file_path is None:
55
- return "", None, [], [], "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”."
56
-
57
- ext = Path(file_path).suffix.lower()
58
- if ext not in SUPPORTED_EXTENSIONS:
59
- return "", None, [], [], f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค: {ext}\n์ง€์› ํ˜•์‹: PDF, PNG, JPG"
60
-
61
- page_err = _check_pdf_pages(file_path)
62
- if page_err and page_start == 0 and page_end == 0:
63
- return "", None, [], [], page_err
64
-
65
- try:
66
- combined, mxl_paths, xml_paths, warnings = run_score_pipeline(
67
- input_path=file_path,
68
- preprocess_mode=_PREPROCESS_MAP.get(preprocess, "none"),
69
- dpi=dpi,
70
- upscale_mode=_UPSCALE_MAP.get(upscale, "none"),
71
- on_progress=lambda frac, desc: progress(frac, desc=desc),
72
- correct_xml=False,
73
- page_start=int(page_start),
74
- page_end=int(page_end),
75
- )
76
- except Exception as e:
77
- return "", None, [], [], f"๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:\n{e}"
78
-
79
- # MML ํŒŒ์ผ ์ƒ์„ฑ: ์ž…๋ ฅํŒŒ์ผ๋ช…_mml_YYYYMMDD_HHMMSS.mml
80
- stem = Path(file_path).stem
81
- ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
82
- mml_filename = f"{stem}_mml_{ts}.mml"
83
- mml_path = Path(tempfile.gettempdir()) / mml_filename
84
- mml_path.write_text(combined, encoding="utf-8")
85
-
86
- warnings_text = "\n".join(f"[WARN] {w}" for w in warnings) if warnings else "๊ฒฝ๊ณ  ์—†์Œ"
87
- return combined, str(mml_path), mxl_paths, xml_paths, warnings_text
88
-
89
-
90
- def build_ui() -> gr.Blocks:
91
- with gr.Blocks(title="์•…๋ณด โ†’ MML ๋ณ€ํ™˜๊ธฐ") as demo:
92
- gr.Markdown("# ์•…๋ณด โ†’ MML ๋ณ€ํ™˜๊ธฐ")
93
- gr.Markdown("PDF ๋˜๋Š” ์ด๋ฏธ์ง€ ์•…๋ณด๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ๋งˆ๋น„๋…ธ๊ธฐ MML๋กœ ๋ณ€ํ™˜ํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค.")
94
-
95
- with gr.Row():
96
- with gr.Column():
97
- file_input = gr.File(
98
- label="์•…๋ณด ํŒŒ์ผ (PDF, PNG, JPG)",
99
- file_types=[".pdf", ".png", ".jpg", ".jpeg"],
100
- )
101
- preprocess_radio = gr.Radio(
102
- choices=["์—†์Œ", "Otsu", "Adaptive", "๋Œ€๋น„๊ฐ•ํ™”"],
103
- value="์—†์Œ",
104
- label="์ „์ฒ˜๋ฆฌ (์—†์Œ=์›๋ณธ, Otsu=๊นจ๋—ํ•œ์Šค์บ”, Adaptive=์กฐ๋ช…๋ถˆ๊ท ์ผ, ๋Œ€๋น„๊ฐ•ํ™”=ํ๋ฆฐ์Šค์บ”)",
105
- )
106
- dpi = gr.Radio(
107
- choices=[150, 300, 450, 600],
108
- value=300,
109
- label="DPI (PDF๋งŒ ํ•ด๋‹น, 300 ๊ถŒ์žฅ)",
110
- )
111
- upscale_radio = gr.Radio(
112
- choices=["์—†์Œ", "PIL 2ร—", "PIL 3ร—"],
113
- value="์—†์Œ",
114
- label="์—…์Šค์ผ€์ผ (PIL ๋ฆฌ์‚ฌ์ด์ฆˆ โ€” ์ €ํ•ด์ƒ๋„ ์Šค์บ”์— ํšจ๊ณผ์ )",
115
- )
116
- with gr.Row():
117
- page_start_input = gr.Number(
118
- value=0, label="์‹œ์ž‘ ํŽ˜์ด์ง€ (0=์ฒ˜์Œ๋ถ€ํ„ฐ)", minimum=0, precision=0,
119
- )
120
- page_end_input = gr.Number(
121
- value=0, label="๋ ํŽ˜์ด์ง€ (0=๋๊นŒ์ง€)", minimum=0, precision=0,
122
- )
123
- convert_btn = gr.Button("๋ณ€ํ™˜ ์‹œ์ž‘", variant="primary")
124
-
125
- with gr.Column():
126
- mml_output = gr.Textbox(
127
- label="MML ๊ฒฐ๊ณผ (์œ„: ์ „์ฒดํ™”์Œ NํŒŒํŠธ / ๊ตฌ๋ถ„์„  / ์•„๋ž˜: 3ํŒŒํŠธ)",
128
- lines=25,
129
- placeholder="๋ณ€ํ™˜ ๊ฒฐ๊ณผ๊ฐ€ ์—ฌ๊ธฐ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.",
130
- )
131
- mml_file_output = gr.File(
132
- label="MML ๋‹ค์šด๋กœ๋“œ",
133
- file_count="single",
134
- )
135
- mxl_output = gr.File(
136
- label="MXL ๋‹ค์šด๋กœ๋“œ (์••์ถ• MusicXML)",
137
- file_count="multiple",
138
- )
139
- xml_output = gr.File(
140
- label="XML ๋‹ค์šด๋กœ๋“œ (Soundslice ๋“ฑ ํ˜ธํ™˜)",
141
- file_count="multiple",
142
- )
143
- warnings_output = gr.Textbox(
144
- label="๊ฒฝ๊ณ ",
145
- lines=4,
146
- placeholder="๊ฒฝ๊ณ  ๋ฉ”์‹œ์ง€",
147
- )
148
-
149
- convert_btn.click(
150
- fn=convert,
151
- inputs=[file_input, preprocess_radio, dpi, upscale_radio, page_start_input, page_end_input],
152
- outputs=[mml_output, mml_file_output, mxl_output, xml_output, warnings_output],
153
- )
154
-
155
- return demo
156
-
157
-
158
- if __name__ == "__main__":
159
- parser = argparse.ArgumentParser()
160
- parser.add_argument("--share", action="store_true", help="์™ธ๋ถ€ ๊ณต์œ  ๋งํฌ ์ƒ์„ฑ")
161
- parser.add_argument("--port", type=int, default=7860, help="ํฌํŠธ ๋ฒˆํ˜ธ (๊ธฐ๋ณธ: 7860)")
162
- args = parser.parse_args()
163
-
164
- demo = build_ui()
165
- demo.queue(max_size=10, default_concurrency_limit=1)
166
- demo.launch(
167
- share=args.share,
168
- server_name="0.0.0.0",
169
- server_port=args.port,
170
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app_gradio.py
3
+
4
+ Gradio ์›น UI โ€” ์•…๋ณด(PDF/PNG/JPG) ์—…๋กœ๋“œ โ†’ MML ๋ณ€ํ™˜ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜.
5
+
6
+ ์‹คํ–‰:
7
+ python app_gradio.py
8
+
9
+ ์™ธ๋ถ€ ์ ‘์†:
10
+ python app_gradio.py --share
11
+
12
+ Hugging Face Spaces ๋ฐฐํฌ:
13
+ Dockerfile์—์„œ ENTRYPOINT๋กœ ์‹คํ–‰๋จ
14
+ """
15
+
16
+ import argparse
17
+ import datetime
18
+ import json
19
+ import logging
20
+ import os
21
+ import re
22
+ import tempfile
23
+ import threading
24
+ from pathlib import Path
25
+ from typing import List
26
+
27
+ import gradio as gr
28
+
29
+ from core.convert_pipeline import run_score_pipeline
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ SUPPORTED_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg"]
34
+ MAX_PDF_PAGES = int(os.environ.get("MAX_PDF_PAGES", "5"))
35
+ _SEP = "-" * 40
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Persistent stats via Hugging Face Dataset repo
39
+ # ---------------------------------------------------------------------------
40
+ _STATS_REPO = os.environ.get("STATS_REPO", "Coconuttttt/score-to-mml-stats")
41
+ _STATS_FILE = "stats.json"
42
+ _stats_lock = threading.Lock()
43
+
44
+ # Real-time active conversion counter
45
+ _active_lock = threading.Lock()
46
+ _active_count = 0
47
+
48
+
49
+ def _hf_api():
50
+ """Get HfApi instance (lazy import โ€” huggingface_hub ships with gradio)."""
51
+ try:
52
+ from huggingface_hub import HfApi
53
+ token = os.environ.get("HF_TOKEN")
54
+ return HfApi(token=token) if token else None
55
+ except ImportError:
56
+ return None
57
+
58
+
59
+ def _load_stats() -> dict:
60
+ """Download stats.json from HF Dataset repo. Returns dict or empty."""
61
+ api = _hf_api()
62
+ if not api:
63
+ return {}
64
+ try:
65
+ from huggingface_hub import hf_hub_download
66
+ token = os.environ.get("HF_TOKEN")
67
+ path = hf_hub_download(
68
+ repo_id=_STATS_REPO, filename=_STATS_FILE,
69
+ repo_type="dataset", token=token,
70
+ force_download=True,
71
+ )
72
+ with open(path, encoding="utf-8") as f:
73
+ return json.load(f)
74
+ except Exception:
75
+ return {}
76
+
77
+
78
+ def _save_stats(data: dict):
79
+ """Upload stats.json to HF Dataset repo."""
80
+ api = _hf_api()
81
+ if not api:
82
+ return
83
+ try:
84
+ tmp = Path(tempfile.gettempdir()) / _STATS_FILE
85
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
86
+ api.upload_file(
87
+ path_or_fileobj=str(tmp),
88
+ path_in_repo=_STATS_FILE,
89
+ repo_id=_STATS_REPO,
90
+ repo_type="dataset",
91
+ )
92
+ except Exception as e:
93
+ logger.warning("stats save failed: %s", e)
94
+
95
+
96
+ def _bump_stat(key: str, extra: dict | None = None):
97
+ """Increment today's counter + hourly bucket (thread-safe).
98
+ extra: optional dict to merge into today's record (e.g. error details).
99
+ """
100
+ now = datetime.datetime.now()
101
+ today = now.strftime("%Y-%m-%d")
102
+ hour = now.strftime("%H")
103
+ with _stats_lock:
104
+ data = _load_stats()
105
+ if today not in data:
106
+ data[today] = {"visits": 0, "conversions": 0, "errors": 0, "hourly": {}}
107
+ # Migrate old format
108
+ if "hourly" not in data[today]:
109
+ data[today]["hourly"] = {}
110
+ if "errors" not in data[today]:
111
+ data[today]["errors"] = 0
112
+ # Daily total
113
+ data[today][key] = data[today].get(key, 0) + 1
114
+ # Hourly bucket
115
+ if hour not in data[today]["hourly"]:
116
+ data[today]["hourly"][hour] = {"visits": 0, "conversions": 0, "errors": 0}
117
+ data[today]["hourly"][hour][key] = data[today]["hourly"][hour].get(key, 0) + 1
118
+ # Track peak concurrent
119
+ with _active_lock:
120
+ cur = _active_count
121
+ peak = data[today].get("peak_concurrent", 0)
122
+ if cur > peak:
123
+ data[today]["peak_concurrent"] = cur
124
+ # Merge extra (e.g. recent error messages)
125
+ if extra:
126
+ if "recent_errors" not in data[today]:
127
+ data[today]["recent_errors"] = []
128
+ data[today]["recent_errors"].append(extra)
129
+ # Keep only last 10 errors per day
130
+ data[today]["recent_errors"] = data[today]["recent_errors"][-10:]
131
+ _save_stats(data)
132
+
133
+
134
+ def _record_visit():
135
+ """Called on page load via demo.load()."""
136
+ threading.Thread(target=_bump_stat, args=("visits",), daemon=True).start()
137
+
138
+
139
+ def _record_conversion():
140
+ """Called after successful conversion."""
141
+ threading.Thread(target=_bump_stat, args=("conversions",), daemon=True).start()
142
+
143
+
144
+ def _record_error(error_msg: str):
145
+ """Called on conversion failure โ€” logs error type + message."""
146
+ now = datetime.datetime.now().strftime("%H:%M:%S")
147
+ short = str(error_msg)[:200]
148
+ extra = {"time": now, "msg": short}
149
+ threading.Thread(target=_bump_stat, args=("errors", extra), daemon=True).start()
150
+
151
+
152
+ def _get_dashboard() -> str:
153
+ """Build admin dashboard text from stats."""
154
+ with _active_lock:
155
+ active = _active_count
156
+ data = _load_stats()
157
+ today = datetime.date.today().isoformat()
158
+ lines = []
159
+ lines.append(f"=== ์‹ค์‹œ๊ฐ„ ===")
160
+ lines.append(f"ํ˜„์žฌ ๋ณ€ํ™˜ ์ค‘: {active}๊ฑด")
161
+ lines.append("")
162
+
163
+ # Today
164
+ td = data.get(today, {})
165
+ lines.append(f"=== ์˜ค๋Š˜ ({today}) ===")
166
+ lines.append(f"๋ฐฉ๋ฌธ: {td.get('visits', 0)} | ๋ณ€ํ™˜: {td.get('conversions', 0)} | ์—๋Ÿฌ: {td.get('errors', 0)} | ํ”ผํฌ ๋™์‹œ: {td.get('peak_concurrent', 0)}")
167
+ hourly = td.get("hourly", {})
168
+ if hourly:
169
+ lines.append("")
170
+ lines.append("์‹œ๊ฐ„๋Œ€๋ณ„:")
171
+ for h in sorted(hourly.keys()):
172
+ hd = hourly[h]
173
+ err_str = f" ์—๋Ÿฌ: {hd.get('errors',0):>3}" if hd.get('errors', 0) else ""
174
+ lines.append(f" {h}์‹œ ๋ฐฉ๋ฌธ: {hd.get('visits',0):>3} ๋ณ€ํ™˜: {hd.get('conversions',0):>3}{err_str}")
175
+
176
+ # Recent errors today
177
+ recent_errs = td.get("recent_errors", [])
178
+ if recent_errs:
179
+ lines.append("")
180
+ lines.append("์ตœ๊ทผ ์—๋Ÿฌ:")
181
+ for e in recent_errs[-5:]:
182
+ lines.append(f" [{e.get('time','')}] {e.get('msg','')[:100]}")
183
+ lines.append("")
184
+
185
+ # Recent 7 days summary
186
+ lines.append("=== ์ตœ๊ทผ 7์ผ ===")
187
+ lines.append(f"{'๋‚ ์งœ':<12} {'๋ฐฉ๋ฌธ':>5} {'๋ณ€ํ™˜':>5} {'์—๋Ÿฌ':>4} {'ํ”ผํฌ':>4}")
188
+ lines.append("-" * 38)
189
+ dates = sorted(data.keys(), reverse=True)[:7]
190
+ total_v, total_c, total_e = 0, 0, 0
191
+ for d in dates:
192
+ dd = data[d]
193
+ v = dd.get("visits", 0)
194
+ c = dd.get("conversions", 0)
195
+ e = dd.get("errors", 0)
196
+ p = dd.get("peak_concurrent", 0)
197
+ total_v += v
198
+ total_c += c
199
+ total_e += e
200
+ lines.append(f"{d:<12} {v:>5} {c:>5} {e:>4} {p:>4}")
201
+ lines.append("-" * 38)
202
+ lines.append(f"{'ํ•ฉ๊ณ„':<12} {total_v:>5} {total_c:>5} {total_e:>4}")
203
+
204
+ return "\n".join(lines)
205
+
206
+ _PREPROCESS_MAP = {"์—†์Œ": "none", "Otsu": "otsu", "Adaptive": "adaptive", "๋Œ€๋น„๊ฐ•ํ™”": "contrast"}
207
+ _UPSCALE_MAP = {
208
+ "์—†์Œ": "none",
209
+ "PIL 2ร—": "pil_2", "PIL 3ร—": "pil_3",
210
+ }
211
+
212
+
213
+ def _extract_mml_parts(text: str) -> List[str]:
214
+ """Extract MML content from MML@...; patterns."""
215
+ matches = re.findall(r'MML@([^;]*);', text, re.IGNORECASE)
216
+ return matches
217
+
218
+
219
+ def _to_mabi_tracks(text: str) -> List[str]:
220
+ """Convert Part-labeled MML text to MabiIcco track strings.
221
+
222
+ Mabinogi: 1 track = max 3 channels (melody, chord, bass).
223
+ N parts โ†’ ceil(N/3) tracks, each: MML@ch1,ch2,ch3;
224
+ Empty channels filled with empty string: MML@p7,,;
225
+ """
226
+ parts = _extract_mml_parts(text)
227
+ if not parts:
228
+ return []
229
+ tracks = []
230
+ for i in range(0, len(parts), 3):
231
+ chunk = parts[i:i + 3]
232
+ while len(chunk) < 3:
233
+ chunk.append("")
234
+ tracks.append("MML@" + ",".join(chunk) + ";")
235
+ return tracks
236
+
237
+
238
+ def _make_track_files(text: str, prefix: str, ts: str, tmpdir: Path) -> List[str]:
239
+ """Generate one .mml file per track, return list of file paths."""
240
+ tracks = _to_mabi_tracks(text)
241
+ if not tracks:
242
+ return []
243
+ paths = []
244
+ for i, track in enumerate(tracks, 1):
245
+ p = tmpdir / f"{prefix}_track{i}_{ts}.mml"
246
+ p.write_text(track, encoding="utf-8")
247
+ paths.append(str(p))
248
+ return paths
249
+
250
+
251
+ _MABI_CHAR_LIMIT = 1500 # ๋งˆ๋น„๋…ธ๊ธฐ MML ์ฑ„๋„๋‹น ๊ธ€์ž์ˆ˜ ์ œํ•œ (๊ทผ์‚ฌ๊ฐ’)
252
+
253
+
254
+ def _mml_char_info(text: str, label: str) -> str:
255
+ """Generate per-part character count summary. Warns if over limit."""
256
+ parts = _extract_mml_parts(text)
257
+ if not parts:
258
+ return ""
259
+ lines = [f"[{label} ๊ธ€์ž์ˆ˜]"]
260
+ for i, p in enumerate(parts, 1):
261
+ full = f"MML@{p};"
262
+ n = len(full)
263
+ warn = f" *** {_MABI_CHAR_LIMIT}์ž ์ดˆ๊ณผ!" if n > _MABI_CHAR_LIMIT else ""
264
+ lines.append(f" Part {i}: {n}์ž{warn}")
265
+ return "\n".join(lines)
266
+
267
+
268
+ def _check_pdf_pages(file_path: str) -> str | None:
269
+ if Path(file_path).suffix.lower() != ".pdf":
270
+ return None
271
+ try:
272
+ import fitz
273
+ doc = fitz.open(file_path)
274
+ count = doc.page_count
275
+ doc.close()
276
+ if count > MAX_PDF_PAGES:
277
+ return f"PDF๊ฐ€ {count}ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. ์ตœ๋Œ€ {MAX_PDF_PAGES}ํŽ˜์ด์ง€๊นŒ์ง€ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค."
278
+ except Exception:
279
+ pass
280
+ return None
281
+
282
+
283
+ def convert(file_path, preprocess, dpi_str, upscale, page_start, page_end, progress=gr.Progress()):
284
+ global _active_count
285
+ empty = ("", "", [], [], [], [], [], "")
286
+ if file_path is None:
287
+ return (*empty[:7], "ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”.")
288
+
289
+ ext = Path(file_path).suffix.lower()
290
+ if ext not in SUPPORTED_EXTENSIONS:
291
+ return (*empty[:7], f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค: {ext}\n์ง€์› ํ˜•์‹: PDF, PNG, JPG")
292
+
293
+ page_err = _check_pdf_pages(file_path)
294
+ ps = int(page_start) if page_start else 0
295
+ pe = int(page_end) if page_end else 0
296
+ if page_err and ps == 0 and pe == 0:
297
+ return (*empty[:7], page_err)
298
+
299
+ dpi = int(dpi_str) if dpi_str else 300
300
+
301
+ # Use a persistent work_dir so we can retrieve page PNGs for preview
302
+ work_dir = tempfile.mkdtemp(prefix="sml_ui_")
303
+
304
+ with _active_lock:
305
+ _active_count += 1
306
+ try:
307
+ combined, mxl_paths, xml_paths, warnings = run_score_pipeline(
308
+ input_path=file_path,
309
+ preprocess_mode=_PREPROCESS_MAP.get(preprocess, "none"),
310
+ dpi=dpi,
311
+ upscale_mode=_UPSCALE_MAP.get(upscale, "none"),
312
+ on_progress=lambda frac, desc: progress(frac, desc=desc),
313
+ correct_xml=False,
314
+ save_dir=work_dir,
315
+ page_start=ps,
316
+ page_end=pe,
317
+ )
318
+ except Exception as e:
319
+ with _active_lock:
320
+ _active_count -= 1
321
+ _record_error(str(e))
322
+ return (*empty[:7], f"๋ณ€ํ™˜ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:\n{e}")
323
+
324
+ with _active_lock:
325
+ _active_count -= 1
326
+
327
+ # Collect page preview images from work_dir/pages/
328
+ pages_dir = Path(work_dir) / "pages"
329
+ preview_images = sorted(pages_dir.glob("*.png")) if pages_dir.exists() else []
330
+ # Filter: show preprocessed versions if they exist, otherwise originals
331
+ pre_imgs = [p for p in preview_images if "_pre" in p.name or "_norm" in p.name]
332
+ if not pre_imgs:
333
+ pre_imgs = [p for p in preview_images if not p.name.endswith(("_norm.png",))]
334
+ # Convert to string paths for gr.Gallery
335
+ preview_paths = [str(p) for p in (pre_imgs if pre_imgs else preview_images)]
336
+
337
+ # Split combined into Whole Part and 3 Part
338
+ if _SEP in combined:
339
+ parts = combined.split(_SEP, 1)
340
+ whole_text = parts[0].strip()
341
+ three_text = parts[1].strip()
342
+ else:
343
+ whole_text = combined.strip()
344
+ three_text = ""
345
+
346
+ # Generate MabiIcco-compatible MML files (1 file per track)
347
+ stem = Path(file_path).stem
348
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
349
+ tmpdir = Path(tempfile.gettempdir())
350
+
351
+ whole_files = _make_track_files(whole_text, f"{stem}_whole", ts, tmpdir)
352
+ three_files = _make_track_files(three_text, f"{stem}_3part", ts, tmpdir) if three_text else []
353
+
354
+ _record_conversion()
355
+
356
+ # Build warnings + char count info
357
+ info_parts = []
358
+ if warnings:
359
+ info_parts.append("\n".join(f"[WARN] {w}" for w in warnings))
360
+ else:
361
+ info_parts.append("๊ฒฝ๊ณ  ์—†์Œ")
362
+ whole_info = _mml_char_info(whole_text, "Whole Part")
363
+ three_info = _mml_char_info(three_text, "3 Part")
364
+ if whole_info:
365
+ info_parts.append(whole_info)
366
+ if three_info:
367
+ info_parts.append(three_info)
368
+ warnings_text = "\n\n".join(info_parts)
369
+
370
+ return (
371
+ whole_text,
372
+ three_text,
373
+ whole_files,
374
+ three_files,
375
+ mxl_paths,
376
+ xml_paths,
377
+ preview_paths,
378
+ warnings_text,
379
+ )
380
+
381
+
382
+ def _apply_tempo(whole_text: str, three_text: str, tempo: int):
383
+ """๊ฐ MML@...; ํŒŒํŠธ์˜ ํ…œํฌ๋ฅผ T{tempo}๋กœ ๊ต์ฒด/์‚ฝ์ž…ํ•˜๊ณ  ๋‹ค์šด๋กœ๋“œ ํŒŒ์ผ ์žฌ์ƒ์„ฑ."""
384
+ tempo = max(32, min(255, int(tempo)))
385
+
386
+ def replace_tempo(text):
387
+ if not text:
388
+ return text
389
+ # MML@T120... โ†’ MML@T{tempo}... or MML@c4... โ†’ MML@T{tempo}c4...
390
+ return re.sub(r'(MML@)(T\d+)?', rf'\1T{tempo}', text, flags=re.IGNORECASE)
391
+
392
+ new_whole = replace_tempo(whole_text)
393
+ new_three = replace_tempo(three_text)
394
+
395
+ # Regenerate download files (1 per track)
396
+ ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
397
+ tmpdir = Path(tempfile.gettempdir())
398
+
399
+ whole_files = _make_track_files(new_whole, f"tempo{tempo}_whole", ts, tmpdir) if new_whole else []
400
+ three_files = _make_track_files(new_three, f"tempo{tempo}_3part", ts, tmpdir) if new_three else []
401
+
402
+ return (
403
+ new_whole,
404
+ new_three,
405
+ whole_files,
406
+ three_files,
407
+ )
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # MML Player JS โ€” gr.Button.click(fn=None, js="() => {...}") is the only
412
+ # reliable way to execute JS in Gradio (innerHTML/head/js all fail).
413
+ # ---------------------------------------------------------------------------
414
+
415
+ _JS_INIT = """
416
+ if(!window._MP){
417
+ window._MP={actx:null,playing:[],stop:false};
418
+ window._MP.parse=function(mml){
419
+ var notes=[],tempo=120,oct=4,dL=4,vol=8,i=0;
420
+ var s=mml.toUpperCase().replace(/\\s+/g,'');
421
+ var nm={C:0,D:2,E:4,F:5,G:7,A:9,B:11};
422
+ function rn(){var n='';while(i<s.length&&s[i]>='0'&&s[i]<='9'){n+=s[i];i++;}return n?parseInt(n):0;}
423
+ function ls(l){var b=60/tempo*4/l,d=0;while(i<s.length&&s[i]==='.'){d++;i++;}var x=b;for(var j=0;j<d;j++){x/=2;b+=x;}return b;}
424
+ while(i<s.length){
425
+ var c=s[i];
426
+ if(c==='T'){i++;tempo=rn()||120;}
427
+ else if(c==='O'){i++;oct=rn()||4;}
428
+ else if(c==='L'){i++;dL=rn()||4;}
429
+ else if(c==='V'){i++;vol=rn();if(vol>15)vol=15;}
430
+ else if(c==='>'){i++;oct=Math.min(oct+1,8);}
431
+ else if(c==='<'){i++;oct=Math.max(oct-1,1);}
432
+ else if(c==='&'){i++;}
433
+ else if(c==='N'){i++;var nn=rn();var ll=rn()||dL;notes.push({m:nn,d:ls(ll),v:vol});}
434
+ else if(nm[c]!==undefined){
435
+ i++;var sm=nm[c];
436
+ if(i<s.length&&s[i]==='+'){sm++;i++;}
437
+ else if(i<s.length&&(s[i]==='-'||s[i]==='#')){if(s[i]==='#')sm++;else sm--;i++;}
438
+ var ll2=rn()||dL;var dur=ls(ll2);notes.push({m:(oct+1)*12+sm,d:dur,v:vol});
439
+ }
440
+ else if(c==='R'){i++;var ll3=rn()||dL;notes.push({m:-1,d:ls(ll3),v:0});}
441
+ else{i++;}
442
+ }
443
+ return notes;
444
+ };
445
+ window._MP.freq=function(m){return 440*Math.pow(2,(m-69)/12);};
446
+ window._MP.playN=function(nl){
447
+ var P=window._MP;
448
+ if(!P.actx)P.actx=new(window.AudioContext||window.webkitAudioContext)();
449
+ if(P.actx.state==='suspended')P.actx.resume();
450
+ var t=P.actx.currentTime+0.05;
451
+ for(var i=0;i<nl.length;i++){
452
+ var n=nl[i];if(P.stop)break;if(n.m<0){t+=n.d;continue;}
453
+ var o=P.actx.createOscillator(),g=P.actx.createGain(),v=n.v/15*0.3;
454
+ o.type='triangle';o.frequency.value=P.freq(n.m);
455
+ g.gain.setValueAtTime(v,t);g.gain.exponentialRampToValueAtTime(0.001,t+n.d*0.95);
456
+ o.connect(g);g.connect(P.actx.destination);o.start(t);o.stop(t+n.d);
457
+ P.playing.push(o);t+=n.d;
458
+ }
459
+ return t-P.actx.currentTime;
460
+ };
461
+ window._MP.getFrom=function(elemId){
462
+ var box=document.getElementById(elemId);
463
+ if(box){var ta=box.querySelector('textarea');if(ta&&ta.value)return ta.value.trim();}
464
+ return '';
465
+ };
466
+ window._MP.extract=function(t){var m=t.match(/MML@[^;]*;/gi)||[];return m.map(function(s){return s.replace(/^MML@/i,'').replace(/;$/,'');});};
467
+ window._MP.doStop=function(){
468
+ var P=window._MP;P.stop=true;
469
+ P.playing.forEach(function(o){try{o.stop();}catch(e){}});P.playing=[];
470
+ };
471
+ window._MP.go=function(mmlArr,label){
472
+ var P=window._MP;P.doStop();P.stop=false;
473
+ if(!mmlArr.length){alert('์žฌ์ƒํ•  MML์ด ์—†์Šต๋‹ˆ๋‹ค');return;}
474
+ var total=0,maxD=0;
475
+ for(var i=0;i<mmlArr.length;i++){
476
+ var notes=P.parse(mmlArr[i]);total+=notes.length;
477
+ var d=P.playN(notes);if(d>maxD)maxD=d;
478
+ }
479
+ if(total===0){alert('ํŒŒ์‹ฑ๋œ ๋…ธํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค');return;}
480
+ alert(label+' ์žฌ์ƒ ์‹œ์ž‘ ('+total+'๊ฐœ ๋…ธํŠธ, '+mmlArr.length+'ํŠธ๋ž™)');
481
+ };
482
+ }
483
+ """
484
+
485
+
486
+ def _make_box_play_js(elem_id: str, label: str, part_index: int | None = None) -> str:
487
+ """Play all MML parts from a specific textbox, or a single part by index."""
488
+ if part_index is not None:
489
+ action = (
490
+ f"var parts=P.extract(text);"
491
+ f"if(parts.length>{part_index})P.go([parts[{part_index}]],'{label}');"
492
+ f"else alert('{label} ํŒŒํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค');"
493
+ )
494
+ else:
495
+ action = f"var parts=P.extract(text);P.go(parts,'{label}');"
496
+ return "() => {" + _JS_INIT + f"""
497
+ var P=window._MP;
498
+ var text=P.getFrom('{elem_id}');
499
+ if(!text){{alert('MML ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € ์•…๋ณด๋ฅผ ๋ณ€ํ™˜ํ•˜์„ธ์š”.');}}
500
+ else{{{action}}}
501
+ }}"""
502
+
503
+
504
+ _JS_STOP = "() => {" + _JS_INIT + "window._MP.doStop();alert('์ •์ง€');}"
505
+
506
+ _JS_TEST = "() => {" + _JS_INIT + """
507
+ var P=window._MP;P.doStop();P.stop=false;
508
+ var mml='T120O5G8E8E4F8D8D4C8D8E8F8G4G4G8E8E8E8F8D8D8D8C8E8G8G8E2';
509
+ var notes=P.parse(mml);
510
+ P.playN(notes);
511
+ alert('๋‚˜๋น„์•ผ ํ…Œ์ŠคํŠธ ์žฌ์ƒ ('+notes.length+'๊ฐœ ๋…ธํŠธ)');
512
+ }"""
513
+
514
+
515
+ def build_ui() -> gr.Blocks:
516
+ with gr.Blocks(title="์•…๋ณด โ†’ MML ๋ณ€ํ™˜๊ธฐ") as demo:
517
+ gr.Markdown("# ์•…๋ณด โ†’ MML ๋ณ€ํ™˜๊ธฐ")
518
+ gr.Markdown("PDF ๋˜๋Š” ์ด๋ฏธ์ง€ ์•…๋ณด๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ๋งˆ๋น„๋…ธ๊ธฐ MML๋กœ ๋ณ€ํ™˜ํ•ด๋“œ๋ฆฝ๋‹ˆ๋‹ค.")
519
+
520
+ with gr.Row():
521
+ with gr.Column():
522
+ file_input = gr.File(
523
+ label="์•…๋ณด ํŒŒ์ผ (PDF, PNG, JPG)",
524
+ file_types=[".pdf", ".png", ".jpg", ".jpeg"],
525
+ )
526
+ preprocess_radio = gr.Radio(
527
+ choices=["์—†์Œ", "Otsu", "Adaptive", "๋Œ€๋น„๊ฐ•ํ™”"],
528
+ value="์—†์Œ",
529
+ label="์ „์ฒ˜๋ฆฌ (์—†์Œ=์›๋ณธ, Otsu=๊นจ๋—ํ•œ์Šค์บ”, Adaptive=์กฐ๋ช…๋ถˆ๊ท ์ผ, ๋Œ€๋น„๊ฐ•ํ™”=ํ๋ฆฐ์Šค์บ”)",
530
+ )
531
+ dpi = gr.Dropdown(
532
+ choices=["150", "300", "450", "600"],
533
+ value="300",
534
+ label="DPI (PDF๋งŒ ํ•ด๋‹น, 300 ๊ถŒ์žฅ)",
535
+ )
536
+ upscale_radio = gr.Radio(
537
+ choices=["์—†์Œ", "PIL 2ร—", "PIL 3ร—"],
538
+ value="์—†์Œ",
539
+ label="์—…์Šค์ผ€์ผ (PIL ๋ฆฌ์‚ฌ์ด์ฆˆ โ€” ์ €ํ•ด์ƒ๋„ ์Šค์บ”์— ํšจ๊ณผ์ )",
540
+ )
541
+ with gr.Row():
542
+ page_start_input = gr.Number(
543
+ value=0, label="์‹œ์ž‘ ํŽ˜์ด์ง€ (0=์ฒ˜์Œ๋ถ€ํ„ฐ)", minimum=0, precision=0,
544
+ )
545
+ page_end_input = gr.Number(
546
+ value=0, label="๋ ํŽ˜์ด์ง€ (0=๋๊นŒ์ง€)", minimum=0, precision=0,
547
+ )
548
+ convert_btn = gr.Button("๋ณ€ํ™˜ ์‹œ์ž‘", variant="primary")
549
+
550
+ with gr.Column():
551
+ # ์ž…๋ ฅ ์ด๋ฏธ์ง€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ
552
+ preview_gallery = gr.Gallery(
553
+ label="์ž…๋ ฅ ์•…๋ณด ๋ฏธ๋ฆฌ๋ณด๊ธฐ",
554
+ columns=2,
555
+ height=200,
556
+ object_fit="contain",
557
+ )
558
+
559
+ # Whole Part (NํŒŒํŠธ ์ „์ฒดํ™”์Œ)
560
+ gr.Markdown("#### Whole Part (์ „์ฒดํ™”์Œ)")
561
+ whole_output = gr.Textbox(
562
+ label="์ „์ฒดํ™”์Œ NํŒŒํŠธ MML",
563
+ lines=12,
564
+ placeholder="์ „์ฒดํ™”์Œ ๊ฒฐ๊ณผ",
565
+ elem_id="mml-whole",
566
+ )
567
+ whole_file = gr.File(label="Whole Part ๋‹ค์šด๋กœ๋“œ (ํŠธ๋ž™๋ณ„ ํŒŒ์ผ)", file_count="multiple")
568
+ with gr.Row():
569
+ whole_play_btn = gr.Button("โ–ถ Whole Part ์žฌ์ƒ", variant="primary", size="sm")
570
+ stop_btn = gr.Button("โ–  ์ •์ง€", size="sm")
571
+
572
+ # 3 Part (๋ฉœ๋กœ๋””/ํ™”์Œ/๋ฒ ์ด์Šค)
573
+ gr.Markdown("#### 3 Part (๋ฉœ๋กœ๋”” / ํ™”์Œ / ๋ฒ ์ด์Šค)")
574
+ three_output = gr.Textbox(
575
+ label="3ํŒŒํŠธ MML (Part1=๋ฉœ๋กœ๋””, Part2=ํ™”์Œ, Part3=๋ฒ ์ด์Šค)",
576
+ lines=12,
577
+ placeholder="3ํŒŒํŠธ ๊ฒฐ๊ณผ",
578
+ elem_id="mml-three",
579
+ )
580
+ three_file = gr.File(label="3 Part ๋‹ค์šด๋กœ๋“œ (ํŠธ๋ž™๋ณ„ ํŒŒ์ผ)", file_count="multiple")
581
+ with gr.Row():
582
+ three_play_btn = gr.Button("โ–ถ 3 Part ์ „์ฒด", size="sm")
583
+ melody_btn = gr.Button("โ–ถ ๋ฉœ๋กœ๋””", size="sm")
584
+ chord_btn = gr.Button("โ–ถ ํ™”์Œ", size="sm")
585
+ bass_btn = gr.Button("โ–ถ ๋ฒ ์ด์Šค", size="sm")
586
+
587
+ # ๋ฐ•์ž (Tempo) ์กฐ์ ˆ
588
+ gr.Markdown("#### ๋ฐ•์ž ์กฐ์ ˆ")
589
+ with gr.Row():
590
+ tempo_slider = gr.Slider(
591
+ minimum=32, maximum=255, value=120, step=1,
592
+ label="Tempo (32~255, ๊ธฐ๋ณธ: 120)",
593
+ elem_id="tempo-slider",
594
+ )
595
+ tempo_btn = gr.Button("๋ฐ•์ž ์ ์šฉ", size="sm")
596
+
597
+ # ๊ธฐํƒ€ ๋‹ค์šด๋กœ๋“œ
598
+ mxl_output = gr.File(label="MXL ๋‹ค์šด๋กœ๋“œ", file_count="multiple")
599
+ xml_output = gr.File(label="XML ๋‹ค์šด๋กœ๋“œ", file_count="multiple")
600
+ warnings_output = gr.Textbox(label="๊ฒฝ๊ณ ", lines=3, placeholder="๊ฒฝ๊ณ  ๋ฉ”์‹œ์ง€")
601
+
602
+ # ํ…Œ์ŠคํŠธ
603
+ with gr.Row():
604
+ test_btn = gr.Button("๋‚˜๋น„์•ผ ํ…Œ์ŠคํŠธ", size="sm")
605
+
606
+ # Admin dashboard
607
+ with gr.Accordion("ํ†ต๊ณ„ ๋Œ€์‹œ๋ณด๋“œ", open=False):
608
+ dashboard_output = gr.Textbox(
609
+ label="์„œ๋ฒ„ ํ†ต๊ณ„",
610
+ lines=20,
611
+ interactive=False,
612
+ elem_id="dashboard",
613
+ )
614
+ refresh_btn = gr.Button("์ƒˆ๋กœ๊ณ ์นจ", size="sm")
615
+ refresh_btn.click(fn=_get_dashboard, outputs=[dashboard_output])
616
+
617
+ # Wire up playback JS
618
+ stop_btn.click(fn=None, js=_JS_STOP)
619
+ whole_play_btn.click(fn=None, js=_make_box_play_js("mml-whole", "Whole Part"))
620
+ three_play_btn.click(fn=None, js=_make_box_play_js("mml-three", "3 Part"))
621
+ melody_btn.click(fn=None, js=_make_box_play_js("mml-three", "๋ฉœ๋กœ๋””", 0))
622
+ chord_btn.click(fn=None, js=_make_box_play_js("mml-three", "ํ™”์Œ", 1))
623
+ bass_btn.click(fn=None, js=_make_box_play_js("mml-three", "๋ฒ ์ด์Šค", 2))
624
+ test_btn.click(fn=None, js=_JS_TEST)
625
+
626
+ # Wire up tempo
627
+ tempo_btn.click(
628
+ fn=_apply_tempo,
629
+ inputs=[whole_output, three_output, tempo_slider],
630
+ outputs=[whole_output, three_output, whole_file, three_file],
631
+ )
632
+
633
+ # Wire up conversion
634
+ convert_btn.click(
635
+ fn=convert,
636
+ inputs=[file_input, preprocess_radio, dpi, upscale_radio, page_start_input, page_end_input],
637
+ outputs=[whole_output, three_output, whole_file, three_file,
638
+ mxl_output, xml_output, preview_gallery, warnings_output],
639
+ )
640
+
641
+ # Record page visit
642
+ demo.load(fn=_record_visit)
643
+
644
+ return demo
645
+
646
+
647
+ if __name__ == "__main__":
648
+ parser = argparse.ArgumentParser()
649
+ parser.add_argument("--share", action="store_true", help="์™ธ๋ถ€ ๊ณต์œ  ๋งํฌ ์ƒ์„ฑ")
650
+ parser.add_argument("--port", type=int, default=7860, help="ํฌํŠธ ๋ฒˆํ˜ธ (๊ธฐ๋ณธ: 7860)")
651
+ args = parser.parse_args()
652
+
653
+ demo = build_ui()
654
+ demo.queue(max_size=10, default_concurrency_limit=1)
655
+ demo.launch(
656
+ share=args.share,
657
+ server_name="0.0.0.0",
658
+ server_port=args.port,
659
+ )