Phoe2004 commited on
Commit
c6a8b5d
Β·
verified Β·
1 Parent(s): 9a8bd7b

Upload 4 files

Browse files
Files changed (5) hide show
  1. .gitattributes +1 -0
  2. Dockerfile +4 -0
  3. NotoSansMyanmar-Bold.ttf +3 -0
  4. app.py +93 -13
  5. index.html +154 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ NotoSansMyanmar-Bold.ttf filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -3,6 +3,9 @@ FROM python:3.11-slim
3
  # System deps + deno (yt-dlp JS challenge solver)
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
  ffmpeg git curl unzip \
 
 
 
6
  && curl -fsSL https://deno.land/install.sh | sh \
7
  && rm -rf /var/lib/apt/lists/*
8
 
@@ -28,6 +31,7 @@ RUN pip install --no-cache-dir -U "yt-dlp[default]"
28
  COPY app.py .
29
  COPY bot.py .
30
  COPY index.html .
 
31
  COPY m_youtube_com_cookies.txt .
32
  COPY start.sh .
33
  RUN chmod +x start.sh
 
3
  # System deps + deno (yt-dlp JS challenge solver)
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
  ffmpeg git curl unzip \
6
+ libass9 libass-dev \
7
+ fonts-noto fonts-noto-cjk \
8
+ && fc-cache -fv \
9
  && curl -fsSL https://deno.land/install.sh | sh \
10
  && rm -rf /var/lib/apt/lists/*
11
 
 
31
  COPY app.py .
32
  COPY bot.py .
33
  COPY index.html .
34
+ COPY NotoSansMyanmar-Bold.ttf .
35
  COPY m_youtube_com_cookies.txt .
36
  COPY start.sh .
37
  RUN chmod +x start.sh
NotoSansMyanmar-Bold.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0f20a5e23dfa2efd0fd98b384d8c1c6ab379810985b79e0ae4369936dec6897b
3
+ size 209068
app.py CHANGED
@@ -614,6 +614,58 @@ def api_draft():
614
  except Exception as e:
615
  return jsonify(ok=False, msg=f'❌ {e}')
616
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  # ── #7: Audio filter β€” louder, cleaner voice (no hiss/air noise) ──
618
  def _build_audio_filter(mpath, ad):
619
  """
@@ -632,7 +684,7 @@ def _build_audio_filter(mpath, ad):
632
  return f'[1:a]{voice_chain}[outa]'
633
 
634
  # ── #6: Video render β€” smaller output file ──
635
- def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file):
636
  raw_ratio = ad / vd
637
 
638
  # ── Step 1: Pre-process video β€” resize + fix even dims using -vf (no filter_complex quoting issues) ──
@@ -692,11 +744,19 @@ def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file):
692
  else:
693
  v_layout = f'[0:v]{base_str}'
694
 
 
695
  if wmk:
696
  cn = wmk.replace("'","").replace("\\","").replace(":","")
697
- vff = f"{v_layout},drawtext=text='{cn}':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2[outv]"
 
 
 
 
 
 
 
698
  else:
699
- vff = f'{v_layout}[outv]'
700
 
701
  af = _build_audio_filter(mpath, ad)
702
 
@@ -729,12 +789,16 @@ def api_process():
729
  voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
730
  engine = request.form.get('engine', 'ms')
731
  spd = int(request.form.get('speed', 30))
732
- wmk = request.form.get('watermark', '')
733
- crop = request.form.get('crop', '9:16')
734
- flip = request.form.get('flip', '0') == '1'
735
- col = request.form.get('color', '0') == '1'
736
- video_file = request.files.get('video_file')
737
- music_file = request.files.get('music_file')
 
 
 
 
738
 
739
  if not u: return jsonify(ok=False, msg='❌ Not logged in')
740
  if not sc: return jsonify(ok=False, msg='❌ No script')
@@ -785,7 +849,13 @@ def api_process():
785
  if vd <= 0: raise Exception('Video duration read failed')
786
  if ad <= 0: raise Exception('Audio duration read failed')
787
 
788
- _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file)
 
 
 
 
 
 
789
 
790
  rem = -1
791
  if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'vd')
@@ -831,8 +901,12 @@ def api_process_all():
831
  wmk = request.form.get('watermark', '')
832
  crop = request.form.get('crop', '9:16')
833
  flip = request.form.get('flip', '0') == '1'
834
- col = request.form.get('color', '0') == '1'
835
- ct = request.form.get('content_type', 'Movie Recap')
 
 
 
 
836
  api = request.form.get('ai_model', 'Gemini')
837
  video_file = request.files.get('video_file')
838
  music_file = request.files.get('music_file')
@@ -909,7 +983,13 @@ def api_process_all():
909
  if vd <= 0: raise Exception('Video duration read failed')
910
  if ad <= 0: raise Exception('Audio duration read failed')
911
 
912
- _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file)
 
 
 
 
 
 
913
 
914
  rem = -1
915
  if not is_adm:
 
614
  except Exception as e:
615
  return jsonify(ok=False, msg=f'❌ {e}')
616
 
617
+
618
+ # ── SUBTITLE HELPERS ──
619
+ FONT_PATH = str(BASE_DIR / 'NotoSansMyanmar-Bold.ttf')
620
+
621
+ def _srt_timestamp(s):
622
+ ms = int(round((s % 1) * 1000))
623
+ s = int(s)
624
+ h, rem = divmod(s, 3600)
625
+ m, sec = divmod(rem, 60)
626
+ return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}"
627
+
628
+ def _build_srt(sentences, parts, srt_path, silence_dur=0.4):
629
+ """TTS parts + sentences β†’ SRT timing file"""
630
+ tts_files = [p for p in parts
631
+ if 'sil' not in os.path.basename(p)
632
+ and 'silence' not in os.path.basename(p)]
633
+ lines = []
634
+ t = 0.0
635
+ for i, sent in enumerate(sentences):
636
+ fdur = dur(tts_files[i]) if i < len(tts_files) else 2.0
637
+ if fdur <= 0: fdur = 2.0
638
+ lines.append(str(i + 1))
639
+ lines.append(f"{_srt_timestamp(t)} --> {_srt_timestamp(t + fdur)}")
640
+ lines.append(sent)
641
+ lines.append('')
642
+ t += fdur + silence_dur
643
+ with open(srt_path, 'w', encoding='utf-8') as f:
644
+ f.write('\n'.join(lines))
645
+
646
+ def _subtitle_filter(srt_path, align=2, margin_v=60, fontsize=20):
647
+ """
648
+ Yellow bold Myanmar subtitle + dark rounded box background.
649
+ align : ASS alignment (2=bottom-center, 5=middle, 8=top-center)
650
+ margin_v: pixel distance from edge (top or bottom depending on align)
651
+ fontsize: font size in points
652
+ """
653
+ safe_path = srt_path.replace("'", "")
654
+ font_opt = ''
655
+ if os.path.exists(FONT_PATH):
656
+ safe_font = FONT_PATH.replace("'", "").replace(":", "\\:")
657
+ font_opt = f":fontsdir='{os.path.dirname(safe_font)}'"
658
+ style = (
659
+ f"Fontsize={fontsize},Bold=1,"
660
+ f"PrimaryColour=&H00FFFF00," # yellow
661
+ f"BackColour=&HAA000000," # dark semi-transparent box
662
+ f"BorderStyle=4," # filled box mode
663
+ f"Outline=0,Shadow=0,"
664
+ f"MarginV={margin_v},"
665
+ f"Alignment={align}"
666
+ )
667
+ return f"subtitles='{safe_path}'{font_opt}:force_style='{style}'"
668
+
669
  # ── #7: Audio filter β€” louder, cleaner voice (no hiss/air noise) ──
670
  def _build_audio_filter(mpath, ad):
671
  """
 
684
  return f'[1:a]{voice_chain}[outa]'
685
 
686
  # ── #6: Video render β€” smaller output file ──
687
+ def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file, sentences=None, parts=None, subtitle=False, srt_path=None, sub_align=2, sub_margin=60, sub_fontsize=20):
688
  raw_ratio = ad / vd
689
 
690
  # ── Step 1: Pre-process video β€” resize + fix even dims using -vf (no filter_complex quoting issues) ──
 
744
  else:
745
  v_layout = f'[0:v]{base_str}'
746
 
747
+ # ── Watermark ──
748
  if wmk:
749
  cn = wmk.replace("'","").replace("\\","").replace(":","")
750
+ base_vff = f"{v_layout},drawtext=text='{cn}':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2"
751
+ else:
752
+ base_vff = v_layout
753
+
754
+ # ── Subtitle burn-in ──
755
+ if subtitle and srt_path and os.path.exists(srt_path):
756
+ sub_f = _subtitle_filter(srt_path, align=sub_align, margin_v=sub_margin, fontsize=sub_fontsize)
757
+ vff = f"{base_vff},{sub_f}[outv]"
758
  else:
759
+ vff = f"{base_vff}[outv]"
760
 
761
  af = _build_audio_filter(mpath, ad)
762
 
 
789
  voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
790
  engine = request.form.get('engine', 'ms')
791
  spd = int(request.form.get('speed', 30))
792
+ wmk = request.form.get('watermark', '')
793
+ crop = request.form.get('crop', '9:16')
794
+ flip = request.form.get('flip', '0') == '1'
795
+ col = request.form.get('color', '0') == '1'
796
+ sub = request.form.get('subtitle', '0') == '1'
797
+ sub_align = int(request.form.get('sub_align', 2))
798
+ sub_margin = int(request.form.get('sub_margin', 60))
799
+ sub_fontsize= int(request.form.get('sub_fontsize', 20))
800
+ video_file = request.files.get('video_file')
801
+ music_file = request.files.get('music_file')
802
 
803
  if not u: return jsonify(ok=False, msg='❌ Not logged in')
804
  if not sc: return jsonify(ok=False, msg='❌ No script')
 
849
  if vd <= 0: raise Exception('Video duration read failed')
850
  if ad <= 0: raise Exception('Audio duration read failed')
851
 
852
+ srt_path = None
853
+ if sub:
854
+ srt_path = f'{tmp_dir}/sub.srt'
855
+ _build_srt(sentences, parts, srt_path)
856
+ _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
857
+ sentences=sentences, parts=parts, subtitle=sub, srt_path=srt_path,
858
+ sub_align=sub_align, sub_margin=sub_margin, sub_fontsize=sub_fontsize)
859
 
860
  rem = -1
861
  if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'vd')
 
901
  wmk = request.form.get('watermark', '')
902
  crop = request.form.get('crop', '9:16')
903
  flip = request.form.get('flip', '0') == '1'
904
+ col = request.form.get('color', '0') == '1'
905
+ sub = request.form.get('subtitle', '0') == '1'
906
+ sub_align = int(request.form.get('sub_align', 2))
907
+ sub_margin = int(request.form.get('sub_margin', 60))
908
+ sub_fontsize= int(request.form.get('sub_fontsize', 20))
909
+ ct = request.form.get('content_type', 'Movie Recap')
910
  api = request.form.get('ai_model', 'Gemini')
911
  video_file = request.files.get('video_file')
912
  music_file = request.files.get('music_file')
 
983
  if vd <= 0: raise Exception('Video duration read failed')
984
  if ad <= 0: raise Exception('Audio duration read failed')
985
 
986
+ srt_path = None
987
+ if sub:
988
+ srt_path = f'{tmp_dir}/sub.srt'
989
+ _build_srt(sentences, parts, srt_path)
990
+ _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
991
+ sentences=sentences, parts=parts, subtitle=sub, srt_path=srt_path,
992
+ sub_align=sub_align, sub_margin=sub_margin, sub_fontsize=sub_fontsize)
993
 
994
  rem = -1
995
  if not is_adm:
index.html CHANGED
@@ -197,6 +197,21 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
197
  .spinning{animation:spin .8s linear infinite}
198
  @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
199
  .fade-in{animation:fadeIn .3s ease forwards}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </style>
201
  </head>
202
  <body>
@@ -320,7 +335,32 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
320
  <div class="checks-grid">
321
  <div class="check-item" id="chk-fl" onclick="togCheck(this,'fl')"><div class="check-box"></div><span><i class="fas fa-arrows-alt-h" style="color:var(--green)"></i> Flip Video</span></div>
322
  <div class="check-item" id="chk-ac" onclick="togCheck(this,'ac')"><div class="check-box"></div><span><i class="fas fa-magic" style="color:var(--violet)"></i> Auto Color</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  </div>
 
324
  </div>
325
 
326
  <!-- SETTINGS -->
@@ -390,6 +430,7 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
390
  <div class="preview-top"><i class="fas fa-play-circle"></i> PREVIEW</div>
391
  <div class="video-wrap">
392
  <video id="preview-video" controls style="display:none"></video>
 
393
  <div class="video-placeholder" id="video-placeholder"><i class="fas fa-film"></i><p>Preview will appear here</p></div>
394
  </div>
395
  <div class="preview-bottom">
@@ -688,6 +729,15 @@ function buildFormData(includeScript){
688
  }
689
  const mf = document.getElementById('music-file').files[0];
690
  if(mf) fd.append('music_file', mf);
 
 
 
 
 
 
 
 
 
691
  if(includeScript){
692
  const sc = document.getElementById('script-in').value.trim();
693
  if(sc) fd.append('script', sc);
@@ -879,6 +929,110 @@ async function loadUsers(){
879
  </table>`;
880
  } catch(e){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">Error: '+e+'</div>'; }
881
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
882
  </script>
883
  </body>
884
  </html>
 
197
  .spinning{animation:spin .8s linear infinite}
198
  @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
199
  .fade-in{animation:fadeIn .3s ease forwards}
200
+
201
+ /* ── SUBTITLE SETTINGS ── */
202
+ .sub-settings{display:none;margin-top:10px;padding:12px;background:var(--bg3);border:1px solid var(--border);border-radius:8px}
203
+ .sub-settings.visible{display:block}
204
+ .sub-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
205
+ .sub-row:last-child{margin-bottom:0}
206
+ .sub-label{font-size:.72rem;color:var(--muted);white-space:nowrap;min-width:72px;font-weight:600}
207
+ .sub-val{font-size:.72rem;color:var(--amber2);font-weight:600;min-width:30px;text-align:right}
208
+ .sub-pos-btns{display:flex;gap:5px;flex:1}
209
+ .sub-pos-btn{flex:1;padding:5px 4px;background:var(--bg);border:1px solid var(--border);border-radius:5px;color:var(--muted);font-size:.7rem;font-weight:600;cursor:pointer;transition:.2s;text-align:center}
210
+ .sub-pos-btn.active{border-color:var(--amber);background:rgba(245,166,35,.08);color:var(--amber2)}
211
+
212
+ /* subtitle canvas overlay */
213
+ .video-wrap{position:relative}
214
+ #sub-canvas{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10}
215
  </style>
216
  </head>
217
  <body>
 
335
  <div class="checks-grid">
336
  <div class="check-item" id="chk-fl" onclick="togCheck(this,'fl')"><div class="check-box"></div><span><i class="fas fa-arrows-alt-h" style="color:var(--green)"></i> Flip Video</span></div>
337
  <div class="check-item" id="chk-ac" onclick="togCheck(this,'ac')"><div class="check-box"></div><span><i class="fas fa-magic" style="color:var(--violet)"></i> Auto Color</span></div>
338
+ <div class="check-item" id="chk-sub" onclick="togSubtitle(this)" style="grid-column:1/-1"><div class="check-box"></div><span><i class="fas fa-closed-captioning" style="color:var(--cyan)"></i> Subtitle (စာတန်ထိုး)</span></div>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- SUBTITLE SETTINGS -->
343
+ <div class="card" id="sub-settings-card" style="display:none">
344
+ <div class="card-label"><i class="fas fa-closed-captioning" style="color:var(--cyan)"></i> SUBTITLE POSITION</div>
345
+ <div class="sub-row">
346
+ <span class="sub-label"><i class="fas fa-arrows-alt-v"></i> Position</span>
347
+ <div class="sub-pos-btns">
348
+ <div class="sub-pos-btn active" id="spos-bottom" onclick="setSubPos('bottom')">⬇ Bottom</div>
349
+ <div class="sub-pos-btn" id="spos-middle" onclick="setSubPos('middle')">⬛ Middle</div>
350
+ <div class="sub-pos-btn" id="spos-top" onclick="setSubPos('top')">⬆ Top</div>
351
+ </div>
352
+ </div>
353
+ <div class="sub-row">
354
+ <span class="sub-label"><i class="fas fa-sort-numeric-up"></i> Margin</span>
355
+ <input type="range" id="sub-margin" min="10" max="200" value="60" oninput="updateSubPreview()">
356
+ <span class="sub-val" id="sub-margin-val">60px</span>
357
+ </div>
358
+ <div class="sub-row">
359
+ <span class="sub-label"><i class="fas fa-text-height"></i> Font Size</span>
360
+ <input type="range" id="sub-fontsize" min="12" max="40" value="20" oninput="updateSubPreview()">
361
+ <span class="sub-val" id="sub-fontsize-val">20</span>
362
  </div>
363
+ <div style="font-size:.7rem;color:var(--muted2);margin-top:6px;text-align:center">πŸ‘† Preview panel ပေါ်မှာ sample text α€•α€±α€«α€Ία€žα€Šα€Ί β€” position ချိန်ပါ</div>
364
  </div>
365
 
366
  <!-- SETTINGS -->
 
430
  <div class="preview-top"><i class="fas fa-play-circle"></i> PREVIEW</div>
431
  <div class="video-wrap">
432
  <video id="preview-video" controls style="display:none"></video>
433
+ <canvas id="sub-canvas" style="display:none"></canvas>
434
  <div class="video-placeholder" id="video-placeholder"><i class="fas fa-film"></i><p>Preview will appear here</p></div>
435
  </div>
436
  <div class="preview-bottom">
 
729
  }
730
  const mf = document.getElementById('music-file').files[0];
731
  if(mf) fd.append('music_file', mf);
732
+ // subtitle params
733
+ const subOn = document.getElementById('chk-sub').classList.contains('checked');
734
+ fd.append('subtitle', subOn ? '1' : '0');
735
+ if(subOn){
736
+ const posMap = {bottom:'2', middle:'5', top:'8'};
737
+ fd.append('sub_align', posMap[SUB_POS] || '2');
738
+ fd.append('sub_margin', document.getElementById('sub-margin').value);
739
+ fd.append('sub_fontsize', document.getElementById('sub-fontsize').value);
740
+ }
741
  if(includeScript){
742
  const sc = document.getElementById('script-in').value.trim();
743
  if(sc) fd.append('script', sc);
 
929
  </table>`;
930
  } catch(e){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">Error: '+e+'</div>'; }
931
  }
932
+
933
+ // ��─ SUBTITLE SETTINGS ──
934
+ let SUB_POS = 'bottom'; // 'top' | 'middle' | 'bottom'
935
+
936
+ function togSubtitle(el){
937
+ el.classList.toggle('checked');
938
+ el.querySelector('.check-box').innerHTML = el.classList.contains('checked') ? '<i class="fas fa-check"></i>' : '';
939
+ const on = el.classList.contains('checked');
940
+ document.getElementById('sub-settings-card').style.display = on ? '' : 'none';
941
+ const canvas = document.getElementById('sub-canvas');
942
+ canvas.style.display = on ? 'block' : 'none';
943
+ if(on) updateSubPreview(); else clearSubCanvas();
944
+ }
945
+
946
+ function setSubPos(pos){
947
+ SUB_POS = pos;
948
+ ['bottom','middle','top'].forEach(p=>{
949
+ document.getElementById('spos-'+p).classList.toggle('active', p===pos);
950
+ });
951
+ // reset margin to sensible default per position
952
+ const defaults = {bottom:60, middle:0, top:60};
953
+ document.getElementById('sub-margin').value = defaults[pos];
954
+ updateSubPreview();
955
+ }
956
+
957
+ function updateSubPreview(){
958
+ const margin = parseInt(document.getElementById('sub-margin').value);
959
+ const fontSize = parseInt(document.getElementById('sub-fontsize').value);
960
+ document.getElementById('sub-margin-val').textContent = margin+'px';
961
+ document.getElementById('sub-fontsize-val').textContent = fontSize;
962
+ drawSubCanvas(margin, fontSize);
963
+ }
964
+
965
+ function clearSubCanvas(){
966
+ const c = document.getElementById('sub-canvas');
967
+ const ctx = c.getContext('2d');
968
+ ctx.clearRect(0, 0, c.width, c.height);
969
+ }
970
+
971
+ function drawSubCanvas(margin, fontSize){
972
+ const wrap = document.querySelector('.video-wrap');
973
+ const c = document.getElementById('sub-canvas');
974
+ c.width = wrap.clientWidth;
975
+ c.height = wrap.clientHeight;
976
+ const ctx = c.getContext('2d');
977
+ ctx.clearRect(0, 0, c.width, c.height);
978
+
979
+ const sampleText = 'α€₯ပမာ စာတန်ထိုး နမူနာ';
980
+ ctx.font = `bold ${fontSize}px "Noto Sans Myanmar", sans-serif`;
981
+ ctx.textAlign = 'center';
982
+
983
+ const tw = ctx.measureText(sampleText).width;
984
+ const pad = 14;
985
+ const bw = tw + pad * 2;
986
+ const bh = fontSize + pad * 1.4;
987
+
988
+ let y;
989
+ if(SUB_POS === 'bottom'){
990
+ y = c.height - margin - bh;
991
+ } else if(SUB_POS === 'top'){
992
+ y = margin;
993
+ } else {
994
+ y = (c.height - bh) / 2;
995
+ }
996
+
997
+ const x = c.width / 2;
998
+
999
+ // Dark blur box background
1000
+ ctx.save();
1001
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
1002
+ const r = 10;
1003
+ const bx = x - bw/2;
1004
+ ctx.beginPath();
1005
+ ctx.moveTo(bx+r, y);
1006
+ ctx.lineTo(bx+bw-r, y);
1007
+ ctx.quadraticCurveTo(bx+bw, y, bx+bw, y+r);
1008
+ ctx.lineTo(bx+bw, y+bh-r);
1009
+ ctx.quadraticCurveTo(bx+bw, y+bh, bx+bw-r, y+bh);
1010
+ ctx.lineTo(bx+r, y+bh);
1011
+ ctx.quadraticCurveTo(bx, y+bh, bx, y+bh-r);
1012
+ ctx.lineTo(bx, y+r);
1013
+ ctx.quadraticCurveTo(bx, y, bx+r, y);
1014
+ ctx.closePath();
1015
+ ctx.fill();
1016
+ ctx.restore();
1017
+
1018
+ // Dashed border
1019
+ ctx.save();
1020
+ ctx.strokeStyle = 'rgba(255,255,255,0.25)';
1021
+ ctx.lineWidth = 1;
1022
+ ctx.setLineDash([4,3]);
1023
+ ctx.strokeRect(x - bw/2, y, bw, bh);
1024
+ ctx.restore();
1025
+
1026
+ // Yellow text
1027
+ ctx.fillStyle = '#FFFF00';
1028
+ ctx.fillText(sampleText, x, y + bh - pad * 0.7);
1029
+ }
1030
+
1031
+ // Redraw canvas on window resize
1032
+ window.addEventListener('resize', ()=>{
1033
+ if(document.getElementById('chk-sub').classList.contains('checked')) updateSubPreview();
1034
+ });
1035
+
1036
  </script>
1037
  </body>
1038
  </html>