Coconuttttt Claude Opus 4.6 commited on
Commit
cfc429a
·
1 Parent(s): 97fd84e

Add corrector tool as iframe tab in Gradio UI

Browse files

- Embed corrector (HTML/JS/CSS) in new "교정 도구" tab via iframe
- Add loadFromData() + postMessage listener for programmatic data loading
- Convert function now returns corrector data (image paths + XML texts as JSON)
- Uses _norm.png images (Audiveris-processed) for coordinate accuracy
- "교정 도구 열기" button sends conversion results to iframe via postMessage
- Dockerfile updated to COPY static/ directory
- allowed_paths includes static dir + temp dir for file serving

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (5) hide show
  1. Dockerfile +1 -0
  2. app_gradio.py +190 -98
  3. static/corrector.css +144 -0
  4. static/corrector.html +130 -0
  5. static/corrector.js +0 -0
Dockerfile CHANGED
@@ -48,6 +48,7 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
48
  # Copy application code
49
  WORKDIR /app
50
  COPY core/ ./core/
 
51
  COPY app_gradio.py .
52
  COPY convert_3part.py .
53
 
 
48
  # Copy application code
49
  WORKDIR /app
50
  COPY core/ ./core/
51
+ COPY static/ ./static/
52
  COPY app_gradio.py .
53
  COPY convert_3part.py .
54
 
app_gradio.py CHANGED
@@ -282,19 +282,19 @@ def _check_pdf_pages(file_path: str) -> str | None:
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
 
@@ -319,7 +319,7 @@ def convert(file_path, preprocess, dpi_str, upscale, page_start, page_end, progr
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
@@ -367,6 +367,36 @@ def convert(file_path, preprocess, dpi_str, upscale, page_start, page_end, progr
367
  info_parts.append(three_info)
368
  warnings_text = "\n\n".join(info_parts)
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  return (
371
  whole_text,
372
  three_text,
@@ -375,6 +405,7 @@ def convert(file_path, preprocess, dpi_str, upscale, page_start, page_end, progr
375
  mxl_paths,
376
  xml_paths,
377
  preview_paths,
 
378
  warnings_text,
379
  )
380
 
@@ -522,102 +553,128 @@ def build_ui() -> gr.Blocks:
522
  size="lg",
523
  )
524
 
525
- with gr.Row():
526
- with gr.Column():
527
- file_input = gr.File(
528
- label="악보 파일 (PDF, PNG, JPG)",
529
- file_types=[".pdf", ".png", ".jpg", ".jpeg"],
530
- )
531
- preprocess_radio = gr.Radio(
532
- choices=["없음", "Otsu", "Adaptive", "대비강화"],
533
- value="없음",
534
- label="전처리 (없음=원본, Otsu=깨끗한스캔, Adaptive=조명불균일, 대비강화=흐린스캔)",
535
- )
536
- dpi = gr.Dropdown(
537
- choices=["150", "300", "450", "600"],
538
- value="300",
539
- label="DPI (PDF만 해당, 300 권장)",
540
- )
541
- upscale_radio = gr.Radio(
542
- choices=["없음", "PIL 2×", "PIL 3×"],
543
- value="없음",
544
- label="업스케일 (PIL 리사이즈 — 저해상도 스캔에 효과적)",
545
- )
546
  with gr.Row():
547
- page_start_input = gr.Number(
548
- value=0, label="시작 페이지 (0=처음부터)", minimum=0, precision=0,
549
- )
550
- page_end_input = gr.Number(
551
- value=0, label="끝 페이지 (0=끝까지)", minimum=0, precision=0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  )
553
- convert_btn = gr.Button("변환 시작", variant="primary")
554
-
555
- with gr.Column():
556
- # 입력 이미지 미리보기
557
- preview_gallery = gr.Gallery(
558
- label="입력 악보 미리보기",
559
- columns=2,
560
- height=200,
561
- object_fit="contain",
 
562
  )
563
-
564
- # Whole Part (N파트 전체화음)
565
- gr.Markdown("#### Whole Part (전체화음)")
566
- whole_output = gr.Textbox(
567
- label="전체화음 N파트 MML",
568
- lines=12,
569
- placeholder="전체화음 결과",
570
- elem_id="mml-whole",
571
- )
572
- whole_file = gr.File(label="Whole Part 다운로드 (트랙별 파일)", file_count="multiple")
573
- with gr.Row():
574
- whole_play_btn = gr.Button("▶ Whole Part 재생", variant="primary", size="sm")
575
- stop_btn = gr.Button("■ 정지", size="sm")
576
-
577
- # 3 Part (멜로디/화음/베이스)
578
- gr.Markdown("#### 3 Part (멜로디 / 화음 / 베이스)")
579
- three_output = gr.Textbox(
580
- label="3파트 MML (Part1=멜로디, Part2=화음, Part3=베이스)",
581
- lines=12,
582
- placeholder="3파트 결과",
583
- elem_id="mml-three",
584
  )
585
- three_file = gr.File(label="3 Part 다운로드 (트랙별 파일)", file_count="multiple")
586
- with gr.Row():
587
- three_play_btn = gr.Button("▶ 3 Part 전체", size="sm")
588
- melody_btn = gr.Button("▶ 멜로디", size="sm")
589
- chord_btn = gr.Button("▶ 화음", size="sm")
590
- bass_btn = gr.Button("▶ 베이스", size="sm")
591
-
592
- # 박자 (Tempo) 조절
593
- gr.Markdown("#### 박자 조절")
594
- with gr.Row():
595
- tempo_slider = gr.Slider(
596
- minimum=32, maximum=255, value=120, step=1,
597
- label="Tempo (32~255, 기본: 120)",
598
- elem_id="tempo-slider",
599
- )
600
- tempo_btn = gr.Button("박자 적용", size="sm")
601
-
602
- # 기타 다운로드
603
- mxl_output = gr.File(label="MXL 다운로드", file_count="multiple")
604
- xml_output = gr.File(label="XML 다운로드", file_count="multiple")
605
- warnings_output = gr.Textbox(label="경고", lines=3, placeholder="경고 메시지")
606
-
607
- # 테스트
608
- with gr.Row():
609
- test_btn = gr.Button("나비야 테스트", size="sm")
610
-
611
- # Admin dashboard
612
- with gr.Accordion("통계 대시보드", open=False):
613
- dashboard_output = gr.Textbox(
614
- label="서버 통계",
615
- lines=20,
616
- interactive=False,
617
- elem_id="dashboard",
618
- )
619
- refresh_btn = gr.Button("새로고침", size="sm")
620
- refresh_btn.click(fn=_get_dashboard, outputs=[dashboard_output])
621
 
622
  # Wire up feedback button
623
  feedback_btn.click(
@@ -625,6 +682,37 @@ def build_ui() -> gr.Blocks:
625
  js="() => { window.open('https://docs.google.com/forms/d/e/1FAIpQLScDoM53RMjDLlftORYHXmZ5kmkN4TTZOIyFIRuVsZhp4RGjEA/viewform', '_blank'); }",
626
  )
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  # Wire up playback JS
629
  stop_btn.click(fn=None, js=_JS_STOP)
630
  whole_play_btn.click(fn=None, js=_make_box_play_js("mml-whole", "Whole Part"))
@@ -646,7 +734,8 @@ def build_ui() -> gr.Blocks:
646
  fn=convert,
647
  inputs=[file_input, preprocess_radio, dpi, upscale_radio, page_start_input, page_end_input],
648
  outputs=[whole_output, three_output, whole_file, three_file,
649
- mxl_output, xml_output, preview_gallery, warnings_output],
 
650
  )
651
 
652
  # Record page visit
@@ -663,8 +752,11 @@ if __name__ == "__main__":
663
 
664
  demo = build_ui()
665
  demo.queue(max_size=10, default_concurrency_limit=1)
 
 
666
  demo.launch(
667
  share=args.share,
668
  server_name="0.0.0.0",
669
  server_port=args.port,
 
670
  )
 
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[:8], "파일을 업로드해주세요.")
288
 
289
  ext = Path(file_path).suffix.lower()
290
  if ext not in SUPPORTED_EXTENSIONS:
291
+ return (*empty[:8], 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[:8], page_err)
298
 
299
  dpi = int(dpi_str) if dpi_str else 300
300
 
 
319
  with _active_lock:
320
  _active_count -= 1
321
  _record_error(str(e))
322
+ return (*empty[:8], f"변환 중 오류 발생:\n{e}")
323
 
324
  with _active_lock:
325
  _active_count -= 1
 
367
  info_parts.append(three_info)
368
  warnings_text = "\n\n".join(info_parts)
369
 
370
+ # Build corrector data: page images + XML texts as JSON for iframe postMessage
371
+ # Must use _norm images — Audiveris processes those, so XML coordinates match them.
372
+ # _normalize_png can resize, so dimensions may differ from _pre or original.
373
+ corrector_images = []
374
+ for mxl_p in mxl_paths:
375
+ stem = Path(mxl_p).stem # e.g., "page_01_x2_pre"
376
+ # Audiveris input is {stem}_norm.png
377
+ norm_path = pages_dir / f"{stem}_norm.png" if pages_dir.exists() else None
378
+ if norm_path and norm_path.exists():
379
+ corrector_images.append(str(norm_path))
380
+ else:
381
+ # fallback: try {stem}.png
382
+ fallback = pages_dir / f"{stem}.png" if pages_dir.exists() else None
383
+ if fallback and fallback.exists():
384
+ corrector_images.append(str(fallback))
385
+ elif preview_paths:
386
+ idx = mxl_paths.index(mxl_p)
387
+ if idx < len(preview_paths):
388
+ corrector_images.append(preview_paths[idx])
389
+ corrector_xmls = []
390
+ for xp in (xml_paths or []):
391
+ try:
392
+ corrector_xmls.append(Path(xp).read_text(encoding="utf-8"))
393
+ except Exception:
394
+ pass
395
+ corrector_data = json.dumps({
396
+ "images": corrector_images,
397
+ "xmls": corrector_xmls,
398
+ }, ensure_ascii=False)
399
+
400
  return (
401
  whole_text,
402
  three_text,
 
405
  mxl_paths,
406
  xml_paths,
407
  preview_paths,
408
+ corrector_data,
409
  warnings_text,
410
  )
411
 
 
553
  size="lg",
554
  )
555
 
556
+ with gr.Tabs():
557
+ # ── Tab 1: 변환 ──────────────────────────────────────
558
+ with gr.Tab("변환"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  with gr.Row():
560
+ with gr.Column():
561
+ file_input = gr.File(
562
+ label="악보 파일 (PDF, PNG, JPG)",
563
+ file_types=[".pdf", ".png", ".jpg", ".jpeg"],
564
+ )
565
+ preprocess_radio = gr.Radio(
566
+ choices=["없음", "Otsu", "Adaptive", "대비강화"],
567
+ value="없음",
568
+ label="전처리 (없음=원본, Otsu=깨끗한스캔, Adaptive=조명불균일, 대비강화=흐린스캔)",
569
+ )
570
+ dpi = gr.Dropdown(
571
+ choices=["150", "300", "450", "600"],
572
+ value="300",
573
+ label="DPI (PDF만 해당, 300 권장)",
574
+ )
575
+ upscale_radio = gr.Radio(
576
+ choices=["없음", "PIL 2×", "PIL 3×"],
577
+ value="없음",
578
+ label="업스케일 (PIL 리사이즈 — 저해상도 스캔에 효과적)",
579
+ )
580
+ with gr.Row():
581
+ page_start_input = gr.Number(
582
+ value=0, label="시작 페이지 (0=처음부터)", minimum=0, precision=0,
583
+ )
584
+ page_end_input = gr.Number(
585
+ value=0, label="끝 페이지 (0=끝까지)", minimum=0, precision=0,
586
+ )
587
+ convert_btn = gr.Button("변환 시작", variant="primary")
588
+
589
+ with gr.Column():
590
+ # 입력 이미지 미리보기
591
+ preview_gallery = gr.Gallery(
592
+ label="입력 악보 미리보기",
593
+ columns=2,
594
+ height=200,
595
+ object_fit="contain",
596
+ )
597
+
598
+ # Whole Part (N파트 전체화음)
599
+ gr.Markdown("#### Whole Part (전체화음)")
600
+ whole_output = gr.Textbox(
601
+ label="전체화음 N파트 MML",
602
+ lines=12,
603
+ placeholder="전체화음 결과",
604
+ elem_id="mml-whole",
605
+ )
606
+ whole_file = gr.File(label="Whole Part 다운로드 (트랙별 파일)", file_count="multiple")
607
+ with gr.Row():
608
+ whole_play_btn = gr.Button("▶ Whole Part 재생", variant="primary", size="sm")
609
+ stop_btn = gr.Button("■ 정지", size="sm")
610
+
611
+ # 3 Part (멜로디/화음/베이스)
612
+ gr.Markdown("#### 3 Part (멜로디 / 화음 / 베이스)")
613
+ three_output = gr.Textbox(
614
+ label="3파트 MML (Part1=멜로디, Part2=화음, Part3=베이스)",
615
+ lines=12,
616
+ placeholder="3파트 결과",
617
+ elem_id="mml-three",
618
+ )
619
+ three_file = gr.File(label="3 Part 다운로드 (트랙별 파일)", file_count="multiple")
620
+ with gr.Row():
621
+ three_play_btn = gr.Button("▶ 3 Part 전체", size="sm")
622
+ melody_btn = gr.Button("▶ 멜로디", size="sm")
623
+ chord_btn = gr.Button("▶ 화음", size="sm")
624
+ bass_btn = gr.Button("▶ 베이스", size="sm")
625
+
626
+ # 박자 (Tempo) 조절
627
+ gr.Markdown("#### 박자 조절")
628
+ with gr.Row():
629
+ tempo_slider = gr.Slider(
630
+ minimum=32, maximum=255, value=120, step=1,
631
+ label="Tempo (32~255, 기본: 120)",
632
+ elem_id="tempo-slider",
633
+ )
634
+ tempo_btn = gr.Button("박자 적용", size="sm")
635
+
636
+ # 기타 다운로드
637
+ mxl_output = gr.File(label="MXL 다운로드", file_count="multiple")
638
+ xml_output = gr.File(label="XML 다운로드", file_count="multiple")
639
+
640
+ # Hidden: corrector data (JSON with image paths + XML texts)
641
+ corrector_data_box = gr.Textbox(
642
+ visible=False,
643
+ elem_id="corrector-data",
644
+ )
645
+
646
+ warnings_output = gr.Textbox(label="경고", lines=3, placeholder="경고 메시지")
647
+
648
+ # 테스트
649
+ with gr.Row():
650
+ test_btn = gr.Button("나비야 테스트", size="sm")
651
+
652
+ # Admin dashboard
653
+ with gr.Accordion("통계 대시보드", open=False):
654
+ dashboard_output = gr.Textbox(
655
+ label="서버 통계",
656
+ lines=20,
657
+ interactive=False,
658
+ elem_id="dashboard",
659
  )
660
+ refresh_btn = gr.Button("새로고침", size="sm")
661
+ refresh_btn.click(fn=_get_dashboard, outputs=[dashboard_output])
662
+
663
+ # ── Tab 2: 교정 도구 (Corrector) ─────────────────────
664
+ with gr.Tab("교정 도구", elem_id="corrector-tab"):
665
+ gr.Markdown("변환 완료 후 **'교정 도구 열기'** 버튼을 누르면 악보 교정이 가능합니다.")
666
+ open_corrector_btn = gr.Button(
667
+ "교정 도구 열기 (변환 결과 자동 로드)",
668
+ variant="primary",
669
+ size="lg",
670
  )
671
+ gr.HTML(
672
+ '<iframe id="corrector-iframe" '
673
+ 'src="/file=/app/static/corrector.html" '
674
+ 'style="width:100%;height:80vh;border:1px solid #444;border-radius:8px;" '
675
+ 'allow="autoplay">'
676
+ '</iframe>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
 
679
  # Wire up feedback button
680
  feedback_btn.click(
 
682
  js="() => { window.open('https://docs.google.com/forms/d/e/1FAIpQLScDoM53RMjDLlftORYHXmZ5kmkN4TTZOIyFIRuVsZhp4RGjEA/viewform', '_blank'); }",
683
  )
684
 
685
+ # Wire up corrector open button — reads hidden corrector-data and postMessages to iframe
686
+ _JS_OPEN_CORRECTOR = """
687
+ () => {
688
+ var box = document.getElementById('corrector-data');
689
+ if (!box) { alert('교정 데이터를 찾을 수 없습니다.'); return; }
690
+ var ta = box.querySelector('textarea');
691
+ var raw = ta ? ta.value : '';
692
+ if (!raw) { alert('먼저 악보를 변환하세요.'); return; }
693
+ try {
694
+ var data = JSON.parse(raw);
695
+ if (!data.images || !data.images.length || !data.xmls || !data.xmls.length) {
696
+ alert('변환 결과가 비어있습니다.'); return;
697
+ }
698
+ // Convert file paths to Gradio /file= URLs
699
+ var imageUrls = data.images.map(function(p) { return '/file=' + p; });
700
+ var iframe = document.getElementById('corrector-iframe');
701
+ if (!iframe || !iframe.contentWindow) {
702
+ alert('교정 도구 iframe을 찾을 수 없습니다.'); return;
703
+ }
704
+ iframe.contentWindow.postMessage({
705
+ type: 'corrector-load',
706
+ images: imageUrls,
707
+ xmls: data.xmls
708
+ }, '*');
709
+ } catch(e) {
710
+ alert('데이터 파싱 오류: ' + e.message);
711
+ }
712
+ }
713
+ """
714
+ open_corrector_btn.click(fn=None, js=_JS_OPEN_CORRECTOR)
715
+
716
  # Wire up playback JS
717
  stop_btn.click(fn=None, js=_JS_STOP)
718
  whole_play_btn.click(fn=None, js=_make_box_play_js("mml-whole", "Whole Part"))
 
734
  fn=convert,
735
  inputs=[file_input, preprocess_radio, dpi, upscale_radio, page_start_input, page_end_input],
736
  outputs=[whole_output, three_output, whole_file, three_file,
737
+ mxl_output, xml_output, preview_gallery, corrector_data_box,
738
+ warnings_output],
739
  )
740
 
741
  # Record page visit
 
752
 
753
  demo = build_ui()
754
  demo.queue(max_size=10, default_concurrency_limit=1)
755
+ # allowed_paths: serve corrector static files + temp dirs for conversion results
756
+ _static_path = str(Path(__file__).resolve().parent / "static")
757
  demo.launch(
758
  share=args.share,
759
  server_name="0.0.0.0",
760
  server_port=args.port,
761
+ allowed_paths=[_static_path, tempfile.gettempdir()],
762
  )
static/corrector.css ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2
+ body { font-family: 'Segoe UI', sans-serif; font-size: 13px; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; }
3
+
4
+ /* Feedback Banner */
5
+ #feedback-bar {
6
+ display: flex; justify-content: center; padding: 6px 12px;
7
+ background: #1a0a2e; border-bottom: 1px solid #444;
8
+ }
9
+ #feedback-btn {
10
+ display: inline-block; padding: 8px 40px;
11
+ background: #e94560; color: #fff; font-size: 22px; font-weight: bold;
12
+ text-decoration: none; border-radius: 6px; letter-spacing: 1px;
13
+ }
14
+ #feedback-btn:hover { background: #ff6b6b; }
15
+
16
+ /* Upload Bar */
17
+ #upload-bar {
18
+ display: flex; align-items: center; gap: 15px; padding: 8px 12px;
19
+ background: #16213e; border-bottom: 1px solid #333;
20
+ font-size: 24px; flex-wrap: wrap;
21
+ }
22
+ #upload-bar label { display: flex; align-items: center; gap: 6px; font-size: 24px; }
23
+ #upload-bar input[type="file"] { width: 340px; font-size: 21px; }
24
+ #upload-bar input[type="number"] { width: 75px; background: #0f3460; border: 1px solid #555; color: #fff; padding: 4px 5px; border-radius: 4px; font-size: 24px; }
25
+ #load-btn { padding: 8px 22px; background: #e94560; color: #fff; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; font-size: 24px; }
26
+ #load-btn:hover { background: #ff6b6b; }
27
+ #load-status { color: #aaa; font-size: 19px; }
28
+ #page-nav { display: inline-flex; align-items: center; gap: 6px; }
29
+ #page-nav button { padding: 4px 12px; background: #0f3460; color: #e0e0e0; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 24px; }
30
+ #page-nav button:hover { background: #1a1a5e; }
31
+ #page-indicator { font-size: 22px; color: #e0e0e0; min-width: 60px; text-align: center; }
32
+
33
+ /* Toolbar */
34
+ #toolbar {
35
+ display: flex; align-items: center; gap: 8px; padding: 6px 12px;
36
+ background: #0f3460; border-bottom: 1px solid #333; flex-wrap: wrap;
37
+ font-size: 24px;
38
+ }
39
+ .tool-group { display: flex; align-items: center; gap: 4px; padding: 0 6px; border-right: 1px solid #333; }
40
+ .tool-group:last-child { border-right: none; }
41
+ .tool-group.right { margin-left: auto; }
42
+ #toolbar button {
43
+ padding: 6px 11px; background: #16213e; color: #e0e0e0; border: 1px solid #555;
44
+ border-radius: 3px; cursor: pointer; font-size: 24px;
45
+ }
46
+ #toolbar button:hover { background: #1a1a5e; border-color: #888; }
47
+ #toolbar button:active { background: #e94560; }
48
+ #btn-download { background: #1b6b1b; border-color: #2a2; }
49
+ #btn-download:hover { background: #2a8a2a; }
50
+ #toolbar label { font-size: 24px; }
51
+ #toolbar input[type="number"] { font-size: 24px; width: 68px; }
52
+ #toolbar input[type="range"] { width: 112px; height: 12px; }
53
+
54
+ #zoom-slider { width: 112px; vertical-align: middle; }
55
+ #zoom-label { font-size: 21px; color: #aaa; min-width: 38px; display: inline-block; }
56
+
57
+ /* Shortcut Reference Bar */
58
+ #shortcut-bar {
59
+ display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 6px 12px;
60
+ background: #12122a; border-bottom: 1px solid #2a2a4a;
61
+ font-size: 20px; color: #888;
62
+ }
63
+ #shortcut-bar b { color: #bbb; }
64
+
65
+ /* Progress Bar */
66
+ #progress-bar-container {
67
+ position: relative; height: 38px; background: #0a0a1a; border-bottom: 1px solid #333;
68
+ cursor: pointer; user-select: none;
69
+ }
70
+ #progress-bar-fill {
71
+ height: 100%; width: 0%; background: linear-gradient(90deg, #e94560, #ff6b6b);
72
+ transition: none; pointer-events: none;
73
+ }
74
+ #progress-time {
75
+ position: absolute; top: 0; right: 8px; line-height: 38px;
76
+ font-size: 15px; color: #aaa; pointer-events: none;
77
+ }
78
+
79
+ /* Main Canvas */
80
+ #canvas-wrapper {
81
+ flex: 1; overflow: auto; background: #111; position: relative;
82
+ }
83
+ #canvas-container {
84
+ position: relative; display: inline-block;
85
+ transform-origin: top left;
86
+ }
87
+ #score-image {
88
+ display: block; user-select: none; -webkit-user-drag: none;
89
+ }
90
+ #marker-svg {
91
+ position: absolute; top: 0; left: 0; pointer-events: none;
92
+ }
93
+
94
+ /* Markers */
95
+ .marker {
96
+ pointer-events: all; cursor: pointer;
97
+ transition: r 0.1s, stroke-width 0.1s;
98
+ }
99
+ .marker:hover { r: 10; }
100
+ .marker.selected { stroke: #ffd700; stroke-width: 3; r: 10; }
101
+ .marker.playback-highlight { stroke: #ff3333; stroke-width: 3; r: 11; fill: rgba(255,50,50,0.9); }
102
+ .marker.modified { stroke: #00ff88; stroke-width: 2.5; filter: drop-shadow(0 0 3px #00ff88); }
103
+ .marker.modified.selected { stroke: #ffd700; stroke-width: 3; filter: drop-shadow(0 0 4px #00ff88); }
104
+ .marker.voice1 { fill: rgba(68, 136, 255, 0.7); stroke: #4488ff; stroke-width: 1; }
105
+ .marker.voice2 { fill: rgba(255, 68, 68, 0.7); stroke: #ff4444; stroke-width: 1; }
106
+ .marker.voice3 { fill: rgba(68, 170, 68, 0.7); stroke: #44aa44; stroke-width: 1; }
107
+ .marker.voice4 { fill: rgba(255, 170, 0, 0.7); stroke: #ffaa00; stroke-width: 1; }
108
+ .marker.rest-marker { fill: rgba(150, 150, 150, 0.4); stroke: #888; stroke-width: 1; r: 6; }
109
+
110
+ /* Accidental label next to marker */
111
+ .acc-label {
112
+ pointer-events: none; font-size: 10px; fill: #ffd700; font-weight: bold;
113
+ }
114
+ /* Ghost marker for add mode */
115
+ .ghost-marker {
116
+ fill: rgba(255, 50, 50, 0.5); stroke: #ff3333; stroke-width: 2.5;
117
+ stroke-dasharray: 4 2;
118
+ }
119
+ .ghost-label {
120
+ fill: #ff3333; font-size: 14px; font-weight: bold; opacity: 0.9;
121
+ }
122
+
123
+ /* Chord Popup */
124
+ #chord-popup {
125
+ position: absolute; background: #16213e; border: 1px solid #e94560;
126
+ border-radius: 6px; padding: 6px 0; z-index: 100; min-width: 140px;
127
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
128
+ }
129
+ #chord-popup.hidden { display: none; }
130
+ #chord-popup-title { padding: 4px 12px; font-size: 11px; color: #aaa; border-bottom: 1px solid #333; }
131
+ #chord-popup-list { list-style: none; }
132
+ #chord-popup-list li {
133
+ padding: 6px 12px; cursor: pointer; font-size: 13px;
134
+ }
135
+ #chord-popup-list li:hover { background: #0f3460; }
136
+ #chord-popup-list li .voice-dot {
137
+ display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px;
138
+ }
139
+
140
+ /* Status Bar */
141
+ #status-bar {
142
+ display: flex; justify-content: space-between; padding: 5px 12px;
143
+ background: #16213e; border-top: 1px solid #333; font-size: 19px; color: #aaa;
144
+ }
static/corrector.html ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OMR Corrector</title>
7
+ <link rel="stylesheet" href="corrector.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- Feedback Banner -->
12
+ <div id="feedback-bar">
13
+ <a id="feedback-btn" href="https://docs.google.com/forms/d/e/1FAIpQLScDoM53RMjDLlftORYHXmZ5kmkN4TTZOIyFIRuVsZhp4RGjEA/viewform" target="_blank" rel="noopener">Feedback</a>
14
+ </div>
15
+
16
+ <!-- Upload Bar -->
17
+ <div id="upload-bar">
18
+ <label>Images: <input type="file" id="image-input" accept=".png,.jpg,.jpeg" multiple></label>
19
+ <label>MusicXML: <input type="file" id="xml-input" accept=".xml,.musicxml,.mxl" multiple></label>
20
+ <label>DPI: <input type="number" id="dpi-input" value="300" min="72" max="1200" step="1"></label>
21
+ <button id="load-btn">Load</button>
22
+ <span id="page-nav">
23
+ <button id="btn-prev-page" title="Previous Page">&laquo;</button>
24
+ <span id="page-indicator">-</span>
25
+ <button id="btn-next-page" title="Next Page">&raquo;</button>
26
+ </span>
27
+ <span id="load-status"></span>
28
+ </div>
29
+
30
+ <!-- Toolbar -->
31
+ <div id="toolbar">
32
+ <div class="tool-group">
33
+ <button id="btn-undo" title="Undo (Ctrl+Z)">&#8617;</button>
34
+ <button id="btn-redo" title="Redo (Ctrl+Y)">&#8618;</button>
35
+ </div>
36
+ <div class="tool-group">
37
+ <button id="btn-up" title="Pitch Up (Arrow Up)">&#9650;</button>
38
+ <button id="btn-down" title="Pitch Down (Arrow Down)">&#9660;</button>
39
+ </div>
40
+ <div class="tool-group">
41
+ <button id="btn-sharp" title="Sharp (#)">#</button>
42
+ <button id="btn-flat" title="Flat (b)">b</button>
43
+ <button id="btn-natural" title="Natural (n)">&#9838;</button>
44
+ <button id="btn-delete" title="Delete Note (Del)">&#128465;</button>
45
+ </div>
46
+ <div class="tool-group">
47
+ <button id="btn-dur-whole" title="Whole Note (1)">&#119133;</button>
48
+ <button id="btn-dur-half" title="Half Note (2)">&#119134;</button>
49
+ <button id="btn-dur-quarter" title="Quarter Note (4)">&#119135;</button>
50
+ <button id="btn-dur-eighth" title="Eighth Note (5)">&#119136;</button>
51
+ <button id="btn-dur-16th" title="16th Note (6)">&#119137;</button>
52
+ <button id="btn-dur-dot" title="Dot Toggle (.)">&#8226;</button>
53
+ </div>
54
+ <div class="tool-group">
55
+ <button id="btn-prev" title="Previous Note (Shift+Tab)">&lt;</button>
56
+ <button id="btn-next" title="Next Note (Tab)">&gt;</button>
57
+ </div>
58
+ <div class="tool-group">
59
+ <button id="btn-play" title="Play Note Sound">&#9835;</button>
60
+ <button id="btn-playall" title="Play All (Space)">&#9654;</button>
61
+ <button id="btn-stop" title="Stop">&#9632;</button>
62
+ <label>BPM: <input type="number" id="bpm-input" value="120" min="20" max="300" step="5" style="width:94px"></label>
63
+ </div>
64
+ <div class="tool-group">
65
+ <label>Zoom: <input type="range" id="zoom-slider" min="25" max="200" value="100" step="5"><span id="zoom-label">100%</span></label>
66
+ </div>
67
+ <div class="tool-group">
68
+ <label>X off: <input type="number" id="offset-x" value="0" step="1" style="width:84px"></label>
69
+ <label>Y off: <input type="number" id="offset-y" value="0" step="1" style="width:84px"></label>
70
+ </div>
71
+ <div class="tool-group">
72
+ <label>Staff dist: <input type="number" id="staff-dist-input" value="65" step="1" style="width:84px"></label>
73
+ <label>Sys dist adj: <input type="number" id="sys-dist-adj" value="0" step="1" style="width:84px"></label>
74
+ </div>
75
+ <div class="tool-group">
76
+ <button id="btn-debug-lines" title="Show/hide staff lines">Staff Lines</button>
77
+ </div>
78
+ <div class="tool-group right">
79
+ <button id="btn-download">Download XML</button>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Progress Bar -->
84
+ <div id="progress-bar-container">
85
+ <div id="progress-bar-fill"></div>
86
+ <span id="progress-time">0:00 / 0:00</span>
87
+ </div>
88
+
89
+ <!-- Shortcut Reference -->
90
+ <div id="shortcut-bar">
91
+ <span><b>Select:</b> Click / Tab</span>
92
+ <span><b>Pitch:</b> ↑↓ / Drag</span>
93
+ <span><b>#/b/n:</b> Accidental</span>
94
+ <span><b>1/2/4/5/6:</b> Duration</span>
95
+ <span><b>.</b> Dot</span>
96
+ <span><b>Del:</b> Delete</span>
97
+ <span><b>r:</b> Note↔Rest</span>
98
+ <span><b>Shift+N:</b> Add Mode</span>
99
+ <span><b>Shift+A:</b> Add Chord</span>
100
+ <span><b>Space:</b> Play</span>
101
+ <span><b>Shift+Click:</b> Seek</span>
102
+ <span><b>DblClick:</b> Seek+Play</span>
103
+ <span><b>PgUp/Dn:</b> Page</span>
104
+ <span><b>Ctrl+Z/Y:</b> Undo/Redo</span>
105
+ </div>
106
+
107
+ <!-- Main Canvas Area -->
108
+ <div id="canvas-wrapper">
109
+ <div id="canvas-container">
110
+ <img id="score-image" alt="Score image">
111
+ <svg id="marker-svg" xmlns="http://www.w3.org/2000/svg"></svg>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Chord Popup -->
116
+ <div id="chord-popup" class="hidden">
117
+ <div id="chord-popup-title">Select note:</div>
118
+ <ul id="chord-popup-list"></ul>
119
+ </div>
120
+
121
+ <!-- Status Bar -->
122
+ <div id="status-bar">
123
+ <span id="status-mode" style="color:#ffff66;font-weight:bold;"></span>
124
+ <span id="status-selection">No selection</span>
125
+ <span id="status-total"></span>
126
+ </div>
127
+
128
+ <script src="corrector.js"></script>
129
+ </body>
130
+ </html>
static/corrector.js ADDED
The diff for this file is too large to render. See raw diff