Spaces:
Running
Running
Upload 7 files
Browse files- Dockerfile +10 -32
- app.py +61 -314
- bot.py +1 -1
- index.html +24 -382
- m_youtube_com_cookies.txt +13 -6
Dockerfile
CHANGED
|
@@ -1,49 +1,27 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
#
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
-
ffmpeg
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
ENV DENO_INSTALL="/root/.deno"
|
| 12 |
-
ENV PATH="$DENO_INSTALL/bin:$PATH"
|
| 13 |
-
|
| 14 |
WORKDIR /app
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
RUN pip install --no-cache-dir "numpy<2"
|
| 18 |
-
|
| 19 |
-
# torch CPU only
|
| 20 |
-
RUN pip install --no-cache-dir \
|
| 21 |
-
torch==2.2.2+cpu torchaudio==2.2.2+cpu \
|
| 22 |
-
--index-url https://download.pytorch.org/whl/cpu
|
| 23 |
-
|
| 24 |
COPY requirements.txt .
|
| 25 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
RUN pip install --no-cache-dir -U "yt-dlp[default]"
|
| 29 |
-
|
| 30 |
COPY app.py .
|
| 31 |
-
COPY bot.py .
|
| 32 |
COPY index.html .
|
| 33 |
-
COPY NotoSansMyanmar-Bold.ttf .
|
| 34 |
COPY m_youtube_com_cookies.txt .
|
| 35 |
-
COPY start.sh .
|
| 36 |
-
RUN chmod +x start.sh
|
| 37 |
-
|
| 38 |
-
# Install NotoSansMyanmar font to system so libass can find it
|
| 39 |
-
RUN mkdir -p /usr/local/share/fonts/myanmar \
|
| 40 |
-
&& cp /app/NotoSansMyanmar-Bold.ttf /usr/local/share/fonts/myanmar/ \
|
| 41 |
-
&& fc-cache -fv \
|
| 42 |
-
&& fc-list | grep -i myanmar
|
| 43 |
|
|
|
|
| 44 |
RUN mkdir -p outputs
|
| 45 |
|
| 46 |
-
RUN deno --version && yt-dlp --version && python -c "import numpy; print('numpy', numpy.__version__)"
|
| 47 |
-
|
| 48 |
EXPOSE 7860
|
| 49 |
-
|
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
# Install system dependencies
|
| 4 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
ffmpeg \
|
| 6 |
+
git \
|
| 7 |
+
nodejs \
|
| 8 |
+
npm \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
WORKDIR /app
|
| 12 |
|
| 13 |
+
# Install Python dependencies
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
COPY requirements.txt .
|
| 15 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
|
| 17 |
+
# Copy app files
|
|
|
|
|
|
|
| 18 |
COPY app.py .
|
|
|
|
| 19 |
COPY index.html .
|
|
|
|
| 20 |
COPY m_youtube_com_cookies.txt .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
# Create output directory
|
| 23 |
RUN mkdir -p outputs
|
| 24 |
|
|
|
|
|
|
|
| 25 |
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
CMD ["python", "app.py"]
|
app.py
CHANGED
|
@@ -84,35 +84,12 @@ BASE_DIR = Path(__file__).parent
|
|
| 84 |
COOKIES_FILE = str(BASE_DIR / 'm_youtube_com_cookies.txt')
|
| 85 |
app = Flask(__name__)
|
| 86 |
|
| 87 |
-
# #5: YouTube
|
| 88 |
def ytdlp_download(out_tmpl, video_url, timeout=600):
|
| 89 |
-
"""yt-dlp download — hard cap 720p max,
|
| 90 |
-
url_lower = video_url.lower()
|
| 91 |
-
is_tiktok = 'tiktok.com' in url_lower
|
| 92 |
-
is_facebook = 'facebook.com' in url_lower or 'fb.watch' in url_lower
|
| 93 |
-
is_instagram = 'instagram.com' in url_lower
|
| 94 |
-
|
| 95 |
-
if is_tiktok or is_facebook or is_instagram:
|
| 96 |
-
# These platforms don't always have mp4+m4a splits — use best available ≤720p
|
| 97 |
-
fmt = (
|
| 98 |
-
'bestvideo[height<=720]+bestaudio'
|
| 99 |
-
'/best[height<=720]'
|
| 100 |
-
'/best'
|
| 101 |
-
)
|
| 102 |
-
else:
|
| 103 |
-
# YouTube and others — prefer mp4+m4a for clean merge
|
| 104 |
-
fmt = (
|
| 105 |
-
'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]'
|
| 106 |
-
'/bestvideo[height<=720]+bestaudio'
|
| 107 |
-
'/best[height<=720][ext=mp4]'
|
| 108 |
-
'/best[height<=720]'
|
| 109 |
-
'/best[height<=480]'
|
| 110 |
-
'/best'
|
| 111 |
-
)
|
| 112 |
-
|
| 113 |
cmd = [
|
| 114 |
'yt-dlp', '--no-playlist',
|
| 115 |
-
'-f',
|
| 116 |
'--merge-output-format', 'mp4',
|
| 117 |
'--no-check-certificates',
|
| 118 |
]
|
|
@@ -286,69 +263,16 @@ def create_user_fn(uname, coins, caller):
|
|
| 286 |
save_db(db); return f"✅ '{uname}' created", uname
|
| 287 |
|
| 288 |
# ── AI ──
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
"คุณคือผู้แปลด้านการแพทย์ภาษาไทย — ภาษาไทยที่พูดในชีวิตประจำวัน\n"
|
| 300 |
-
"Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการมากเกินไป | เนื้อหาต้นฉบับเท่านั้น\n"
|
| 301 |
-
"ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
|
| 302 |
-
"Format EXACTLY:\n[SCRIPT](full thai script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #สุขภาพ #thailand #health #viral #trending)"
|
| 303 |
-
)
|
| 304 |
-
else:
|
| 305 |
-
return (
|
| 306 |
-
"คุณคือนักเขียนสคริปต์สรุปหนังภาษาไทย — เล่าแบบสนุก ภาษาพูดธรรมชาติ\n"
|
| 307 |
-
"Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการ | เนื้อหาต้นฉบับเท่านั้น\n"
|
| 308 |
-
"ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
|
| 309 |
-
"แปลเนื้อหาต่อไปนี้เป็นภาษาไทย (สไตล์เล่าเรื่อง movie recap ที่สนุก)\n"
|
| 310 |
-
"ตอบเป็นภาษาไทยเท่านั้น ห้ามมีภาษาอังกฤษในสคริปต์\n"
|
| 311 |
-
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #thailand"
|
| 312 |
-
)
|
| 313 |
-
elif vo_lang == 'en':
|
| 314 |
-
# English language prompts
|
| 315 |
-
if ct == 'Medical/Health':
|
| 316 |
-
return (
|
| 317 |
-
"You are an English medical content translator — use clear, conversational English.\n"
|
| 318 |
-
"Rules: 100% English | conversational tone | original content only\n"
|
| 319 |
-
"Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
|
| 320 |
-
"Format EXACTLY:\n[SCRIPT](full english script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #health #medical #wellness #viral #trending)"
|
| 321 |
-
)
|
| 322 |
-
else:
|
| 323 |
-
return (
|
| 324 |
-
"You are an English movie recap script writer — engaging storytelling tone, conversational.\n"
|
| 325 |
-
"Rules: 100% English | conversational not formal | original content only\n"
|
| 326 |
-
"Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
|
| 327 |
-
"Translate and retell the following content in English (movie recap storytelling style)\n"
|
| 328 |
-
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #english"
|
| 329 |
-
)
|
| 330 |
-
else:
|
| 331 |
-
# Myanmar (default)
|
| 332 |
-
if ct == 'Medical/Health':
|
| 333 |
-
return (
|
| 334 |
-
"မြန်မာ ဆေးဘက် ဘာသာပြန်သူ — spoken Myanmar\n"
|
| 335 |
-
"Rules: 100% မြန်မာ | ကျောင်းသုံးစာပေမသုံးရ | ပုဒ်မတိုင်း ။\n"
|
| 336 |
-
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 337 |
-
"Format EXACTLY:\n[SCRIPT](full myanmar script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #ကျန်းမာရေး #myanmar #health #viral #trending)"
|
| 338 |
-
)
|
| 339 |
-
else:
|
| 340 |
-
return (
|
| 341 |
-
"မြန်မာ movie recap script ရေးသားသူ — spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ)\n"
|
| 342 |
-
"Rules: 100% မြန်မာဘာသာ | ကျောင်းသုံးစာပေမသုံးရ | မူလcontent သာ | ပုဒ်မတိုင်း ။\n"
|
| 343 |
-
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 344 |
-
"Translate the following content into Burmese (storytelling tone movie recap tone and keep original content)\n"
|
| 345 |
-
"မြန်မာလိုပဲ ဖြေပေးပါ။ အင်္ဂလိပ်လို ဘာမှမပြန်နဲ့။အင်္ဂလိပ်စကားလုံးတွေကိုတွေ့ရင်လည်း မြန်မာလိုပဲ ဘာသာပြန်ပြီး ဖြေပေးပါ)\n"
|
| 346 |
-
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #မြန်မာ"
|
| 347 |
-
)
|
| 348 |
-
|
| 349 |
-
# Keep legacy constants for backward compat
|
| 350 |
-
SYS_MOVIE = get_sys_prompt('Movie Recap', 'my')
|
| 351 |
-
SYS_MED = get_sys_prompt('Medical/Health', 'my')
|
| 352 |
|
| 353 |
NUM_TO_MM_RULE = (
|
| 354 |
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — "
|
|
@@ -356,14 +280,6 @@ NUM_TO_MM_RULE = (
|
|
| 356 |
"100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ။"
|
| 357 |
)
|
| 358 |
|
| 359 |
-
def get_num_rule(vo_lang='my'):
|
| 360 |
-
if vo_lang == 'th':
|
| 361 |
-
return "ใช้ตัวเลขไทยเท่านั้น: 1=หนึ่ง, 2=สอง, 10=สิบ, 20=ยี่สิบ, 100=ร้อย, 1000=พัน ห้ามใช้ตัวเลขอารบิก"
|
| 362 |
-
elif vo_lang == 'en':
|
| 363 |
-
return "Write all numbers as English words: 1=one, 2=two, 10=ten, 20=twenty, 100=one hundred, 1000=one thousand — no Arabic digits."
|
| 364 |
-
else:
|
| 365 |
-
return NUM_TO_MM_RULE
|
| 366 |
-
|
| 367 |
def call_api(msgs, api='Gemini'):
|
| 368 |
if api == 'DeepSeek':
|
| 369 |
keys, base, mdl = DEEPSEEK_KEYS, 'https://api.deepseek.com', 'deepseek-chat'
|
|
@@ -402,15 +318,8 @@ def parse_out(text):
|
|
| 402 |
ht = ' '.join(tags[:5])
|
| 403 |
return sc, ti, ht
|
| 404 |
|
| 405 |
-
def split_txt(txt
|
| 406 |
-
|
| 407 |
-
parts = re.split(r'[。\n]', txt)
|
| 408 |
-
return [s.strip() for s in parts if s.strip()] or [txt]
|
| 409 |
-
elif vo_lang == 'en':
|
| 410 |
-
parts = re.split(r'(?<=[.!?])\s+', txt)
|
| 411 |
-
return [s.strip() for s in parts if s.strip()] or [txt]
|
| 412 |
-
else:
|
| 413 |
-
return [s.strip() + '။' for s in re.split(r'[။]', txt) if s.strip()] or [txt]
|
| 414 |
|
| 415 |
def dur(fp):
|
| 416 |
try:
|
|
@@ -441,15 +350,8 @@ def run_tts_sync(sentences, voice_id, rate, tmp_dir):
|
|
| 441 |
loop.close()
|
| 442 |
|
| 443 |
def run_edge_preview(voice_id, rate, out_path):
|
| 444 |
-
# Choose preview text based on voice language
|
| 445 |
-
if voice_id.startswith('th-'):
|
| 446 |
-
text = 'สวัสดีครับ ยินดีต้อนรับ'
|
| 447 |
-
elif voice_id.startswith('en-'):
|
| 448 |
-
text = 'Hello, welcome to Recap Studio.'
|
| 449 |
-
else:
|
| 450 |
-
text = 'မင်္ဂလာပါ။ ကြိုဆိုပါတယ်။'
|
| 451 |
async def _run():
|
| 452 |
-
await edge_tts.Communicate(
|
| 453 |
loop = asyncio.new_event_loop()
|
| 454 |
try:
|
| 455 |
loop.run_until_complete(_run())
|
|
@@ -662,9 +564,8 @@ def api_draft():
|
|
| 662 |
try:
|
| 663 |
u = (request.form.get('username') or '').strip()
|
| 664 |
video_url = (request.form.get('video_url') or '').strip()
|
| 665 |
-
ct
|
| 666 |
-
api
|
| 667 |
-
vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
|
| 668 |
video_file = request.files.get('video_file')
|
| 669 |
|
| 670 |
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
|
@@ -696,19 +597,12 @@ def api_draft():
|
|
| 696 |
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 697 |
tr = res['text']; lang = res.get('language', 'en')
|
| 698 |
|
| 699 |
-
if
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
else:
|
| 706 |
-
sys_p = get_sys_prompt(ct, vo_lang)
|
| 707 |
-
sys_p = sys_p + '\n' + get_num_rule(vo_lang)
|
| 708 |
-
out_txt, key_n = run_stage('ai', call_api,
|
| 709 |
-
[{'role':'system','content':sys_p},
|
| 710 |
-
{'role':'user','content':f'Language:{lang}\n\n{tr}'}], api=api)
|
| 711 |
-
sc, ti, ht = parse_out(out_txt)
|
| 712 |
|
| 713 |
rem = -1
|
| 714 |
if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'tr')
|
|
@@ -737,109 +631,11 @@ def _build_audio_filter(mpath, ad):
|
|
| 737 |
else:
|
| 738 |
return f'[1:a]{voice_chain}[outa]'
|
| 739 |
|
| 740 |
-
# ── Mid-section Audio Sync Correction ──
|
| 741 |
-
def _get_mid_range(duration):
|
| 742 |
-
"""
|
| 743 |
-
Return (start_ratio, end_ratio) for middle section based on total duration.
|
| 744 |
-
"""
|
| 745 |
-
if duration < 180: # < 3 min
|
| 746 |
-
return 0.30, 0.70
|
| 747 |
-
elif duration < 300: # 3–5 min
|
| 748 |
-
return 0.25, 0.75
|
| 749 |
-
elif duration < 600: # 5–10 min
|
| 750 |
-
return 0.20, 0.80
|
| 751 |
-
else: # > 10 min
|
| 752 |
-
return 0.15, 0.85
|
| 753 |
-
|
| 754 |
-
def _fix_mid_sync(audio_path, video_dur, audio_dur, tmp_dir):
|
| 755 |
-
"""
|
| 756 |
-
Split audio into 3 parts: head / middle / tail.
|
| 757 |
-
Apply atempo correction ONLY to middle part if drift > 0.2s.
|
| 758 |
-
Recombine and return new audio path (or original if no fix needed).
|
| 759 |
-
Pitch is preserved (atempo only, no asetrate).
|
| 760 |
-
"""
|
| 761 |
-
drift = audio_dur - video_dur
|
| 762 |
-
if abs(drift) <= 0.2:
|
| 763 |
-
print(f'[sync] drift={drift:.3f}s ≤ 0.2s — skip mid-sync')
|
| 764 |
-
return audio_path
|
| 765 |
-
|
| 766 |
-
s_ratio, e_ratio = _get_mid_range(audio_dur)
|
| 767 |
-
t_start = audio_dur * s_ratio
|
| 768 |
-
t_end = audio_dur * e_ratio
|
| 769 |
-
mid_dur = t_end - t_start
|
| 770 |
-
|
| 771 |
-
# Target mid duration after correction
|
| 772 |
-
# We want total audio ≈ video_dur
|
| 773 |
-
# head + mid_corrected + tail = video_dur
|
| 774 |
-
head_dur = t_start
|
| 775 |
-
tail_dur = audio_dur - t_end
|
| 776 |
-
mid_target = video_dur - head_dur - tail_dur
|
| 777 |
-
|
| 778 |
-
if mid_target <= 0:
|
| 779 |
-
print(f'[sync] mid_target invalid ({mid_target:.3f}s) — skip')
|
| 780 |
-
return audio_path
|
| 781 |
-
|
| 782 |
-
tempo = mid_dur / mid_target
|
| 783 |
-
# atempo range: 0.5 ~ 2.0 (chain if needed)
|
| 784 |
-
tempo = max(0.5, min(2.0, tempo))
|
| 785 |
-
|
| 786 |
-
print(f'[sync] drift={drift:.3f}s | mid {t_start:.2f}s~{t_end:.2f}s | tempo={tempo:.4f}x')
|
| 787 |
-
|
| 788 |
-
head_f = f'{tmp_dir}/sync_head.mp3'
|
| 789 |
-
mid_f = f'{tmp_dir}/sync_mid.mp3'
|
| 790 |
-
tail_f = f'{tmp_dir}/sync_tail.mp3'
|
| 791 |
-
mid_fx = f'{tmp_dir}/sync_mid_fx.mp3'
|
| 792 |
-
out_f = f'{tmp_dir}/sync_fixed.mp3'
|
| 793 |
-
lst_f = f'{tmp_dir}/sync_list.txt'
|
| 794 |
-
|
| 795 |
-
try:
|
| 796 |
-
# Cut head
|
| 797 |
-
subprocess.run(
|
| 798 |
-
f'ffmpeg -y -i "{audio_path}" -ss 0 -t {t_start:.6f} '
|
| 799 |
-
f'-c:a libmp3lame -q:a 2 "{head_f}"',
|
| 800 |
-
shell=True, check=True, capture_output=True)
|
| 801 |
-
|
| 802 |
-
# Cut middle
|
| 803 |
-
subprocess.run(
|
| 804 |
-
f'ffmpeg -y -i "{audio_path}" -ss {t_start:.6f} -t {mid_dur:.6f} '
|
| 805 |
-
f'-c:a libmp3lame -q:a 2 "{mid_f}"',
|
| 806 |
-
shell=True, check=True, capture_output=True)
|
| 807 |
-
|
| 808 |
-
# Cut tail
|
| 809 |
-
subprocess.run(
|
| 810 |
-
f'ffmpeg -y -i "{audio_path}" -ss {t_end:.6f} '
|
| 811 |
-
f'-c:a libmp3lame -q:a 2 "{tail_f}"',
|
| 812 |
-
shell=True, check=True, capture_output=True)
|
| 813 |
-
|
| 814 |
-
# Apply atempo to middle (pitch unchanged)
|
| 815 |
-
subprocess.run(
|
| 816 |
-
f'ffmpeg -y -i "{mid_f}" -af "atempo={tempo:.6f}" '
|
| 817 |
-
f'-c:a libmp3lame -q:a 2 "{mid_fx}"',
|
| 818 |
-
shell=True, check=True, capture_output=True)
|
| 819 |
-
|
| 820 |
-
# Concat head + mid_fixed + tail
|
| 821 |
-
with open(lst_f, 'w') as lf:
|
| 822 |
-
for f in [head_f, mid_fx, tail_f]:
|
| 823 |
-
if os.path.exists(f) and os.path.getsize(f) > 0:
|
| 824 |
-
lf.write(f"file '{os.path.abspath(f)}'\n")
|
| 825 |
-
subprocess.run(
|
| 826 |
-
f'ffmpeg -y -f concat -safe 0 -i "{lst_f}" '
|
| 827 |
-
f'-c:a libmp3lame -q:a 2 "{out_f}"',
|
| 828 |
-
shell=True, check=True, capture_output=True)
|
| 829 |
-
|
| 830 |
-
print(f'[sync] mid-sync done → {out_f}')
|
| 831 |
-
return out_f
|
| 832 |
-
|
| 833 |
-
except Exception as e:
|
| 834 |
-
print(f'[sync] mid-sync failed: {e} — using original audio')
|
| 835 |
-
return audio_path
|
| 836 |
-
|
| 837 |
# ── #6: Video render — smaller output file ──
|
| 838 |
-
def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file
|
| 839 |
-
logo_path=None, logo_x=10, logo_y=10, logo_w=80):
|
| 840 |
raw_ratio = ad / vd
|
| 841 |
|
| 842 |
-
# ── Step 1: Pre-process video — resize + fix even dims ──
|
| 843 |
pre_out = vpath + '_pre.mp4'
|
| 844 |
pre_cmd = (
|
| 845 |
f'ffmpeg -y -hide_banner -loglevel error '
|
|
@@ -850,7 +646,7 @@ def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
|
|
| 850 |
)
|
| 851 |
subprocess.run(pre_cmd, shell=True, check=True)
|
| 852 |
|
| 853 |
-
# ── Step 2: Precise sync calculation ──
|
| 854 |
sync_ratio = max(0.5, min(3.0, raw_ratio))
|
| 855 |
need_loop = raw_ratio > 3.0
|
| 856 |
need_trim = raw_ratio < 0.5
|
|
@@ -866,74 +662,50 @@ def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
|
|
| 866 |
base.append(f'setpts={sync_ratio:.6f}*PTS')
|
| 867 |
if flip: base.append('hflip')
|
| 868 |
if col: base.append('eq=brightness=0.06:contrast=1.2:saturation=1.4')
|
| 869 |
-
|
|
|
|
|
|
|
| 870 |
base.append('format=yuv420p')
|
| 871 |
base_str = ','.join(base)
|
| 872 |
|
| 873 |
if crop == '9:16':
|
| 874 |
-
|
| 875 |
f'[0:v]{base_str},split[s1][s2];'
|
| 876 |
f'[s1]scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:20[bg];'
|
| 877 |
f'[s2]scale=720:1280:force_original_aspect_ratio=decrease[fg];'
|
| 878 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 879 |
)
|
| 880 |
elif crop == '16:9':
|
| 881 |
-
|
| 882 |
f'[0:v]{base_str},split[s1][s2];'
|
| 883 |
f'[s1]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=20:20[bg];'
|
| 884 |
f'[s2]scale=1280:720:force_original_aspect_ratio=decrease[fg];'
|
| 885 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 886 |
)
|
| 887 |
elif crop == '1:1':
|
| 888 |
-
|
| 889 |
f'[0:v]{base_str},split[s1][s2];'
|
| 890 |
f'[s1]scale=720:720:force_original_aspect_ratio=increase,crop=720:720,boxblur=20:20[bg];'
|
| 891 |
f'[s2]scale=720:720:force_original_aspect_ratio=decrease[fg];'
|
| 892 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 893 |
)
|
| 894 |
else:
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
# ── Logo + Watermark — build as single continuous chain, no label re-use ──
|
| 898 |
-
logo_idx = None
|
| 899 |
-
if logo_path and os.path.exists(logo_path):
|
| 900 |
-
logo_idx = 2 if not mpath else 3
|
| 901 |
|
| 902 |
-
if wmk
|
| 903 |
cn = wmk.replace("'","").replace("\\","").replace(":","")
|
| 904 |
-
vff =
|
| 905 |
-
f'{vbase},'
|
| 906 |
-
f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
|
| 907 |
-
f'[vwmk];'
|
| 908 |
-
f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
|
| 909 |
-
f'[vwmk][logo]overlay={logo_x}:{logo_y}[outv]'
|
| 910 |
-
)
|
| 911 |
-
elif wmk:
|
| 912 |
-
cn = wmk.replace("'","").replace("\\","").replace(":","")
|
| 913 |
-
vff = (
|
| 914 |
-
f'{vbase},'
|
| 915 |
-
f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
|
| 916 |
-
f'[outv]'
|
| 917 |
-
)
|
| 918 |
-
elif logo_idx is not None:
|
| 919 |
-
vff = (
|
| 920 |
-
f'{vbase}[vbase];'
|
| 921 |
-
f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
|
| 922 |
-
f'[vbase][logo]overlay={logo_x}:{logo_y}[outv]'
|
| 923 |
-
)
|
| 924 |
else:
|
| 925 |
-
vff = f'{
|
| 926 |
|
| 927 |
af = _build_audio_filter(mpath, ad)
|
| 928 |
|
| 929 |
inp = f'-fflags +genpts+igndts -err_detect ignore_err -i "{pre_out}" -i "{cmb}"'
|
| 930 |
if mpath:
|
| 931 |
inp += f' -stream_loop -1 -i "{mpath}"'
|
| 932 |
-
if logo_idx is not None:
|
| 933 |
-
inp += f' -i "{logo_path}"'
|
| 934 |
|
| 935 |
cmd = (
|
| 936 |
-
f'nice -n
|
| 937 |
f'-filter_complex "{vff};{af}" '
|
| 938 |
f'-map "[outv]" -map "[outa]" '
|
| 939 |
f'-c:v libx264 -crf 26 -preset medium -pix_fmt yuv420p '
|
|
@@ -961,11 +733,11 @@ def api_process():
|
|
| 961 |
crop = request.form.get('crop', '9:16')
|
| 962 |
flip = request.form.get('flip', '0') == '1'
|
| 963 |
col = request.form.get('color', '0') == '1'
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
if
|
| 968 |
-
|
| 969 |
is_adm = (u == ADMIN_U)
|
| 970 |
if not is_adm and get_coins(u) < 1:
|
| 971 |
return jsonify(ok=False, msg='❌ Not enough coins')
|
|
@@ -993,15 +765,8 @@ def api_process():
|
|
| 993 |
mpath = f'{tmp_dir}/music.mp3'
|
| 994 |
music_file.save(mpath)
|
| 995 |
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
logo_x = int(request.form.get('logo_x', 10))
|
| 999 |
-
logo_y = int(request.form.get('logo_y', 10))
|
| 1000 |
-
logo_w = int(request.form.get('logo_w', 80))
|
| 1001 |
-
if logo_file and logo_file.filename:
|
| 1002 |
-
ext = Path(logo_file.filename).suffix or '.png'
|
| 1003 |
-
logo_path = f'{tmp_dir}/logo{ext}'
|
| 1004 |
-
logo_file.save(logo_path)
|
| 1005 |
if engine == 'gemini':
|
| 1006 |
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 1007 |
else:
|
|
@@ -1020,8 +785,10 @@ def api_process():
|
|
| 1020 |
if vd <= 0: raise Exception('Video duration read failed')
|
| 1021 |
if ad <= 0: raise Exception('Audio duration read failed')
|
| 1022 |
|
| 1023 |
-
_build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file
|
| 1024 |
-
|
|
|
|
|
|
|
| 1025 |
return jsonify(ok=True, output_url=f'/outputs/final_{tid}.mp4', coins=rem)
|
| 1026 |
|
| 1027 |
finally:
|
|
@@ -1037,7 +804,7 @@ def api_process():
|
|
| 1037 |
def api_progress(tid):
|
| 1038 |
def generate():
|
| 1039 |
sent_done = False
|
| 1040 |
-
for _ in range(
|
| 1041 |
p = job_progress.get(tid)
|
| 1042 |
if p is None:
|
| 1043 |
yield f"data: {json.dumps({'pct':0,'msg':'Please wait…'})}\n\n"
|
|
@@ -1048,7 +815,7 @@ def api_progress(tid):
|
|
| 1048 |
break
|
| 1049 |
time.sleep(0.4)
|
| 1050 |
if not sent_done:
|
| 1051 |
-
yield f"data: {json.dumps({'pct':0,'msg':'Timeout
|
| 1052 |
return Response(generate(), mimetype='text/event-stream',
|
| 1053 |
headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})
|
| 1054 |
|
|
@@ -1067,17 +834,8 @@ def api_process_all():
|
|
| 1067 |
col = request.form.get('color', '0') == '1'
|
| 1068 |
ct = request.form.get('content_type', 'Movie Recap')
|
| 1069 |
api = request.form.get('ai_model', 'Gemini')
|
| 1070 |
-
vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
|
| 1071 |
-
# Speed default per language (can be overridden by slider)
|
| 1072 |
-
LANG_SPD = {'th': 20, 'en': 0, 'my': 30}
|
| 1073 |
-
if request.form.get('speed') is None:
|
| 1074 |
-
spd = LANG_SPD.get(vo_lang, 30)
|
| 1075 |
video_file = request.files.get('video_file')
|
| 1076 |
music_file = request.files.get('music_file')
|
| 1077 |
-
logo_file = request.files.get('logo_file')
|
| 1078 |
-
logo_x = int(request.form.get('logo_x', 10))
|
| 1079 |
-
logo_y = int(request.form.get('logo_y', 10))
|
| 1080 |
-
logo_w = int(request.form.get('logo_w', 80))
|
| 1081 |
client_tid = (request.form.get('tid') or '').strip()
|
| 1082 |
|
| 1083 |
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
|
@@ -1116,31 +874,21 @@ def api_process_all():
|
|
| 1116 |
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 1117 |
tr = res['text']; src_lang = res.get('language', 'en')
|
| 1118 |
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
sys_p = get_sys_prompt(ct, vo_lang)
|
| 1127 |
-
sys_p = sys_p + '\n' + get_num_rule(vo_lang)
|
| 1128 |
-
out_txt, _ = run_stage('ai', call_api,
|
| 1129 |
-
[{'role':'system','content':sys_p},
|
| 1130 |
-
{'role':'user','content':f'Language:{src_lang}\n\n{tr}'}], api=api)
|
| 1131 |
-
sc, caption_text, hashtags = parse_out(out_txt)
|
| 1132 |
|
| 1133 |
if music_file and music_file.filename:
|
| 1134 |
mpath = f'{tmp_dir}/music.mp3'
|
| 1135 |
music_file.save(mpath)
|
| 1136 |
|
| 1137 |
-
|
| 1138 |
-
if logo_file and logo_file.filename:
|
| 1139 |
-
ext = Path(logo_file.filename).suffix or '.png'
|
| 1140 |
-
logo_path = f'{tmp_dir}/logo{ext}'
|
| 1141 |
-
logo_file.save(logo_path)
|
| 1142 |
rate = f'+{spd}%'
|
| 1143 |
-
sentences = split_txt(sc
|
| 1144 |
if engine == 'gemini':
|
| 1145 |
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 1146 |
else:
|
|
@@ -1161,8 +909,7 @@ def api_process_all():
|
|
| 1161 |
if vd <= 0: raise Exception('Video duration read failed')
|
| 1162 |
if ad <= 0: raise Exception('Audio duration read failed')
|
| 1163 |
|
| 1164 |
-
_build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file
|
| 1165 |
-
logo_path=logo_path, logo_x=logo_x, logo_y=logo_y, logo_w=logo_w)
|
| 1166 |
|
| 1167 |
rem = -1
|
| 1168 |
if not is_adm:
|
|
|
|
| 84 |
COOKIES_FILE = str(BASE_DIR / 'm_youtube_com_cookies.txt')
|
| 85 |
app = Flask(__name__)
|
| 86 |
|
| 87 |
+
# #5: YouTube download default max 720p (never exceeds 720p)
|
| 88 |
def ytdlp_download(out_tmpl, video_url, timeout=600):
|
| 89 |
+
"""yt-dlp download — hard cap 720p max, cookies, robust format fallback."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
cmd = [
|
| 91 |
'yt-dlp', '--no-playlist',
|
| 92 |
+
'-f', 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/bestvideo[height<=720]+bestaudio/best[height<=720][ext=mp4]/best[height<=720]/best[height<=480]/best',
|
| 93 |
'--merge-output-format', 'mp4',
|
| 94 |
'--no-check-certificates',
|
| 95 |
]
|
|
|
|
| 263 |
save_db(db); return f"✅ '{uname}' created", uname
|
| 264 |
|
| 265 |
# ── AI ──
|
| 266 |
+
SYS_MOVIE = ("မြန်မာ movie recap script ရေးသားသူ — spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ)\n"
|
| 267 |
+
"Rules: 100% မြန်မာဘာသာ | ကျောင်းသုံးစာပေမသုံးရ | မူလcontent သာ | ပုဒ်မတိုင်း ။\n"
|
| 268 |
+
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 269 |
+
"Translate the following content into Burmese (storytelling tone movie recap tone and keep original content)\n"
|
| 270 |
+
"မြန်မာလိုပဲ ဖြေပေးပါ။ အင်္ဂလိပ်လို ဘာမှမပြန်နဲ့။အင်္ဂလိပ်စကားလုံးတွေကိုတွေ့ရင်လည်း မြန်မာလိုပဲ ဘာသာပြန်ပြီး ဖြေပေးပါ)\n"
|
| 271 |
+
"Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #မြန်မာ")
|
| 272 |
+
SYS_MED = ("မြန်မာ ဆေးဘက် ဘာသာပြန်သူ — spoken Myanmar\n"
|
| 273 |
+
"Rules: 100% မြန်မာ | ကျောင်းသုံးစာပေမသုံးရ | ပုဒ်မတိုင်း ။\n"
|
| 274 |
+
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
|
| 275 |
+
"Format EXACTLY:\n[SCRIPT](full myanmar script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #ကျန်းမာရေး #myanmar #health #viral #trending)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
NUM_TO_MM_RULE = (
|
| 278 |
"ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — "
|
|
|
|
| 280 |
"100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ။"
|
| 281 |
)
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
def call_api(msgs, api='Gemini'):
|
| 284 |
if api == 'DeepSeek':
|
| 285 |
keys, base, mdl = DEEPSEEK_KEYS, 'https://api.deepseek.com', 'deepseek-chat'
|
|
|
|
| 318 |
ht = ' '.join(tags[:5])
|
| 319 |
return sc, ti, ht
|
| 320 |
|
| 321 |
+
def split_txt(txt):
|
| 322 |
+
return [s.strip() + '။' for s in re.split(r'[။]', txt) if s.strip()] or [txt]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
def dur(fp):
|
| 325 |
try:
|
|
|
|
| 350 |
loop.close()
|
| 351 |
|
| 352 |
def run_edge_preview(voice_id, rate, out_path):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
async def _run():
|
| 354 |
+
await edge_tts.Communicate('မင်္ဂလာပါ။', voice_id, rate=rate).save(out_path)
|
| 355 |
loop = asyncio.new_event_loop()
|
| 356 |
try:
|
| 357 |
loop.run_until_complete(_run())
|
|
|
|
| 564 |
try:
|
| 565 |
u = (request.form.get('username') or '').strip()
|
| 566 |
video_url = (request.form.get('video_url') or '').strip()
|
| 567 |
+
ct = request.form.get('content_type', 'Movie Recap')
|
| 568 |
+
api = request.form.get('ai_model', 'Gemini')
|
|
|
|
| 569 |
video_file = request.files.get('video_file')
|
| 570 |
|
| 571 |
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
|
|
|
| 597 |
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 598 |
tr = res['text']; lang = res.get('language', 'en')
|
| 599 |
|
| 600 |
+
sys_p = SYS_MED if ct == 'Medical/Health' else SYS_MOVIE
|
| 601 |
+
sys_p = sys_p + '\n' + NUM_TO_MM_RULE
|
| 602 |
+
out_txt, key_n = run_stage('ai', call_api,
|
| 603 |
+
[{'role':'system','content':sys_p},
|
| 604 |
+
{'role':'user','content':f'Language:{lang}\n\n{tr}'}], api=api)
|
| 605 |
+
sc, ti, ht = parse_out(out_txt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
|
| 607 |
rem = -1
|
| 608 |
if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'tr')
|
|
|
|
| 631 |
else:
|
| 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) ──
|
| 639 |
pre_out = vpath + '_pre.mp4'
|
| 640 |
pre_cmd = (
|
| 641 |
f'ffmpeg -y -hide_banner -loglevel error '
|
|
|
|
| 646 |
)
|
| 647 |
subprocess.run(pre_cmd, shell=True, check=True)
|
| 648 |
|
| 649 |
+
# ── Step 2: Precise sync calculation (recapfull logic) ──
|
| 650 |
sync_ratio = max(0.5, min(3.0, raw_ratio))
|
| 651 |
need_loop = raw_ratio > 3.0
|
| 652 |
need_trim = raw_ratio < 0.5
|
|
|
|
| 662 |
base.append(f'setpts={sync_ratio:.6f}*PTS')
|
| 663 |
if flip: base.append('hflip')
|
| 664 |
if col: base.append('eq=brightness=0.06:contrast=1.2:saturation=1.4')
|
| 665 |
+
|
| 666 |
+
# Safe max-720p scale — no min() needed since pre-step already fixed dims
|
| 667 |
+
base.append('scale=iw:ih') # dims already fixed by pre-step
|
| 668 |
base.append('format=yuv420p')
|
| 669 |
base_str = ','.join(base)
|
| 670 |
|
| 671 |
if crop == '9:16':
|
| 672 |
+
v_layout = (
|
| 673 |
f'[0:v]{base_str},split[s1][s2];'
|
| 674 |
f'[s1]scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:20[bg];'
|
| 675 |
f'[s2]scale=720:1280:force_original_aspect_ratio=decrease[fg];'
|
| 676 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 677 |
)
|
| 678 |
elif crop == '16:9':
|
| 679 |
+
v_layout = (
|
| 680 |
f'[0:v]{base_str},split[s1][s2];'
|
| 681 |
f'[s1]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=20:20[bg];'
|
| 682 |
f'[s2]scale=1280:720:force_original_aspect_ratio=decrease[fg];'
|
| 683 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 684 |
)
|
| 685 |
elif crop == '1:1':
|
| 686 |
+
v_layout = (
|
| 687 |
f'[0:v]{base_str},split[s1][s2];'
|
| 688 |
f'[s1]scale=720:720:force_original_aspect_ratio=increase,crop=720:720,boxblur=20:20[bg];'
|
| 689 |
f'[s2]scale=720:720:force_original_aspect_ratio=decrease[fg];'
|
| 690 |
f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
|
| 691 |
)
|
| 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 |
|
| 703 |
inp = f'-fflags +genpts+igndts -err_detect ignore_err -i "{pre_out}" -i "{cmb}"'
|
| 704 |
if mpath:
|
| 705 |
inp += f' -stream_loop -1 -i "{mpath}"'
|
|
|
|
|
|
|
| 706 |
|
| 707 |
cmd = (
|
| 708 |
+
f'nice -n 19 ffmpeg -y -hide_banner -loglevel error {inp} '
|
| 709 |
f'-filter_complex "{vff};{af}" '
|
| 710 |
f'-map "[outv]" -map "[outa]" '
|
| 711 |
f'-c:v libx264 -crf 26 -preset medium -pix_fmt yuv420p '
|
|
|
|
| 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')
|
| 741 |
is_adm = (u == ADMIN_U)
|
| 742 |
if not is_adm and get_coins(u) < 1:
|
| 743 |
return jsonify(ok=False, msg='❌ Not enough coins')
|
|
|
|
| 765 |
mpath = f'{tmp_dir}/music.mp3'
|
| 766 |
music_file.save(mpath)
|
| 767 |
|
| 768 |
+
rate = f'+{spd}%'
|
| 769 |
+
sentences = split_txt(sc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
if engine == 'gemini':
|
| 771 |
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 772 |
else:
|
|
|
|
| 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')
|
| 792 |
return jsonify(ok=True, output_url=f'/outputs/final_{tid}.mp4', coins=rem)
|
| 793 |
|
| 794 |
finally:
|
|
|
|
| 804 |
def api_progress(tid):
|
| 805 |
def generate():
|
| 806 |
sent_done = False
|
| 807 |
+
for _ in range(600):
|
| 808 |
p = job_progress.get(tid)
|
| 809 |
if p is None:
|
| 810 |
yield f"data: {json.dumps({'pct':0,'msg':'Please wait…'})}\n\n"
|
|
|
|
| 815 |
break
|
| 816 |
time.sleep(0.4)
|
| 817 |
if not sent_done:
|
| 818 |
+
yield f"data: {json.dumps({'pct':0,'msg':'Timeout','error':True})}\n\n"
|
| 819 |
return Response(generate(), mimetype='text/event-stream',
|
| 820 |
headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})
|
| 821 |
|
|
|
|
| 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')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
client_tid = (request.form.get('tid') or '').strip()
|
| 840 |
|
| 841 |
if not u: return jsonify(ok=False, msg='❌ Not logged in')
|
|
|
|
| 874 |
res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
|
| 875 |
tr = res['text']; src_lang = res.get('language', 'en')
|
| 876 |
|
| 877 |
+
job_progress[tid] = {'pct': 45, 'msg': '🤖 Generating AI script…', 'done': False}
|
| 878 |
+
sys_p = SYS_MED if ct == 'Medical/Health' else SYS_MOVIE
|
| 879 |
+
sys_p = sys_p + '\n' + NUM_TO_MM_RULE
|
| 880 |
+
out_txt, _ = run_stage('ai', call_api,
|
| 881 |
+
[{'role':'system','content':sys_p},
|
| 882 |
+
{'role':'user','content':f'Language:{src_lang}\n\n{tr}'}], api=api)
|
| 883 |
+
sc, caption_text, hashtags = parse_out(out_txt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
|
| 885 |
if music_file and music_file.filename:
|
| 886 |
mpath = f'{tmp_dir}/music.mp3'
|
| 887 |
music_file.save(mpath)
|
| 888 |
|
| 889 |
+
job_progress[tid] = {'pct': 62, 'msg': '🔊 Generating TTS audio…', 'done': False}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 890 |
rate = f'+{spd}%'
|
| 891 |
+
sentences = split_txt(sc)
|
| 892 |
if engine == 'gemini':
|
| 893 |
parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
|
| 894 |
else:
|
|
|
|
| 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:
|
bot.py
CHANGED
|
@@ -481,7 +481,7 @@ async def _do_process(bot, cid, prog_msg_id, pending_url=None, pending_file_id=N
|
|
| 481 |
with open(out_file, 'rb') as vf:
|
| 482 |
await bot.send_video(chat_id=cid, video=vf,
|
| 483 |
caption=f"🎬 *{caption_text}*\n\n{hashtags}\n\n🪙 ကျန် Coins: *{fmt_coins(s['coins'])}*",
|
| 484 |
-
parse_mode=ParseMode.MARKDOWN, supports_streaming=True, read_timeout=
|
| 485 |
await bot.delete_message(chat_id=cid, message_id=prog_msg_id)
|
| 486 |
except Exception as e:
|
| 487 |
logger.exception(f'[{tid}] Error: {e}')
|
|
|
|
| 481 |
with open(out_file, 'rb') as vf:
|
| 482 |
await bot.send_video(chat_id=cid, video=vf,
|
| 483 |
caption=f"🎬 *{caption_text}*\n\n{hashtags}\n\n🪙 ကျန် Coins: *{fmt_coins(s['coins'])}*",
|
| 484 |
+
parse_mode=ParseMode.MARKDOWN, supports_streaming=True, read_timeout=300, write_timeout=300)
|
| 485 |
await bot.delete_message(chat_id=cid, message_id=prog_msg_id)
|
| 486 |
except Exception as e:
|
| 487 |
logger.exception(f'[{tid}] Error: {e}')
|
index.html
CHANGED
|
@@ -95,11 +95,8 @@ body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:1
|
|
| 95 |
.vcard:hover{border-color:var(--border2);background:var(--bg2)}
|
| 96 |
.vcard.selected{border-color:var(--amber);background:rgba(245,166,35,.06)}
|
| 97 |
.vcard-name{font-size:.72rem;font-weight:600;margin-bottom:2px;line-height:1.2}
|
| 98 |
-
.vcard-sub{font-size:.6rem;color:var(--muted);margin-bottom:
|
| 99 |
-
.vcard-play{display:
|
| 100 |
-
.vcard-play:hover{background:rgba(245,166,35,.18)}
|
| 101 |
-
.vcard-play.playing{background:rgba(0,184,148,.08);border-color:rgba(0,184,148,.2);color:var(--green)}
|
| 102 |
-
.vcard-play.gemini-hide{display:none !important}
|
| 103 |
|
| 104 |
/* ── SPEED (hidden until toggle) ── */
|
| 105 |
.speed-toggle{display:flex;align-items:center;gap:6px;margin-top:10px;cursor:pointer;font-size:.78rem;color:var(--muted2);font-weight:500;user-select:none}
|
|
@@ -151,7 +148,7 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 151 |
.preview-top{padding:12px 14px;border-bottom:1px solid var(--border);font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
|
| 152 |
.preview-top i{color:var(--amber)}
|
| 153 |
.video-wrap{aspect-ratio:9/16;background:#f0f1f4;position:relative;max-height:400px;overflow:hidden}
|
| 154 |
-
.video-wrap video{width:100%;height:100%;object-fit:contain
|
| 155 |
.video-placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted)}
|
| 156 |
.video-placeholder i{font-size:2.5rem}
|
| 157 |
.video-placeholder p{font-size:.8rem}
|
|
@@ -200,16 +197,6 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 200 |
.spinning{animation:spin .8s linear infinite}
|
| 201 |
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
| 202 |
.fade-in{animation:fadeIn .3s ease forwards}
|
| 203 |
-
|
| 204 |
-
/* ── LANGUAGE SELECTOR ── */
|
| 205 |
-
.lang-btn{background:var(--bg3);border:1.5px solid var(--border);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;transition:.2s;font-size:.75rem;font-weight:600;color:var(--muted2);user-select:none;line-height:1.6}
|
| 206 |
-
.lang-btn:hover{border-color:var(--border2);color:var(--text)}
|
| 207 |
-
.lang-btn.active{border-color:var(--amber);background:rgba(245,166,35,.08);color:var(--amber2)}
|
| 208 |
-
|
| 209 |
-
/* ── LOGO POSITION GRID ── */
|
| 210 |
-
.pos-btn{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:6px 0;font-size:.9rem;cursor:pointer;transition:.2s;color:var(--muted2);font-family:var(--sans)}
|
| 211 |
-
.pos-btn:hover{border-color:var(--amber);color:var(--amber);background:rgba(245,166,35,.06)}
|
| 212 |
-
.pos-btn.active{border-color:var(--amber);background:rgba(245,166,35,.1);color:var(--amber2)}
|
| 213 |
</style>
|
| 214 |
</head>
|
| 215 |
<body>
|
|
@@ -339,23 +326,6 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 339 |
<!-- SETTINGS -->
|
| 340 |
<div class="card">
|
| 341 |
<div class="card-label"><i class="fas fa-cog"></i> SETTINGS</div>
|
| 342 |
-
|
| 343 |
-
<!-- LANGUAGE SELECTOR -->
|
| 344 |
-
<div style="margin-bottom:12px">
|
| 345 |
-
<div class="field-label"><i class="fas fa-globe"></i> Output Language</div>
|
| 346 |
-
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
|
| 347 |
-
<div class="lang-btn active" id="lang-my" onclick="switchLang('my')">
|
| 348 |
-
<span style="font-size:1.1rem">🇲🇲</span><br><span>Myanmar</span>
|
| 349 |
-
</div>
|
| 350 |
-
<div class="lang-btn" id="lang-th" onclick="switchLang('th')">
|
| 351 |
-
<span style="font-size:1.1rem">🇹🇭</span><br><span>Thailand</span>
|
| 352 |
-
</div>
|
| 353 |
-
<div class="lang-btn" id="lang-en" onclick="switchLang('en')">
|
| 354 |
-
<span style="font-size:1.1rem">🇬🇧</span><br><span>English</span>
|
| 355 |
-
</div>
|
| 356 |
-
</div>
|
| 357 |
-
</div>
|
| 358 |
-
|
| 359 |
<div class="grid2">
|
| 360 |
<div>
|
| 361 |
<div class="field-label"><i class="fas fa-crop-alt"></i> Crop Ratio</div>
|
|
@@ -396,62 +366,6 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 396 |
<span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-music"></i> <span id="music-name">Choose MP3</span></span>
|
| 397 |
</div>
|
| 398 |
</div>
|
| 399 |
-
|
| 400 |
-
<!-- LOGO -->
|
| 401 |
-
<div style="margin-top:8px">
|
| 402 |
-
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
| 403 |
-
<div class="field-label" style="margin:0"><i class="fas fa-image"></i> Logo Overlay</div>
|
| 404 |
-
<div class="check-item" id="chk-logo" onclick="toggleLogo(this)" style="padding:4px 12px;border-radius:20px;font-size:.75rem;width:auto;gap:6px">
|
| 405 |
-
<div class="check-box"></div><span id="logo-lbl">Off</span>
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
<div id="logo-section" style="display:none">
|
| 409 |
-
<div class="upload-area" style="padding:12px" onclick="document.getElementById('logo-file').click()">
|
| 410 |
-
<input type="file" id="logo-file" accept="image/*" style="display:none" onchange="onLogoSelect(this)">
|
| 411 |
-
<span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-image"></i> <span id="logo-name">Choose Logo (PNG/JPG)</span></span>
|
| 412 |
-
</div>
|
| 413 |
-
<!-- Logo position picker -->
|
| 414 |
-
<div id="logo-pos-wrap" style="display:none;margin-top:8px">
|
| 415 |
-
<div class="field-label" style="margin-bottom:6px"><i class="fas fa-crosshairs"></i> Logo Position</div>
|
| 416 |
-
<div style="display:flex;gap:10px;align-items:flex-start">
|
| 417 |
-
<!-- Mini preview -->
|
| 418 |
-
<div id="logo-preview-canvas" style="position:relative;flex-shrink:0;width:90px;aspect-ratio:9/16;background:#1a1d28;border-radius:6px;overflow:hidden;border:1px solid var(--border)">
|
| 419 |
-
<video id="logo-bg-video" style="width:100%;height:100%;object-fit:cover;opacity:0.5;pointer-events:none"></video>
|
| 420 |
-
<img id="logo-drag-img" style="position:absolute;pointer-events:none;filter:drop-shadow(0 1px 3px rgba(0,0,0,.6))" draggable="false">
|
| 421 |
-
</div>
|
| 422 |
-
<!-- Controls -->
|
| 423 |
-
<div style="flex:1">
|
| 424 |
-
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-bottom:8px">
|
| 425 |
-
<button class="pos-btn" onclick="setLogoPos('tl')" title="Top Left">↖</button>
|
| 426 |
-
<button class="pos-btn" onclick="setLogoPos('tc')" title="Top Center">↑</button>
|
| 427 |
-
<button class="pos-btn" onclick="setLogoPos('tr')" title="Top Right">↗</button>
|
| 428 |
-
<button class="pos-btn" onclick="setLogoPos('ml')" title="Mid Left">←</button>
|
| 429 |
-
<button class="pos-btn" onclick="setLogoPos('mc')" title="Center">·</button>
|
| 430 |
-
<button class="pos-btn" onclick="setLogoPos('mr')" title="Mid Right">→</button>
|
| 431 |
-
<button class="pos-btn" onclick="setLogoPos('bl')" title="Bottom Left">↙</button>
|
| 432 |
-
<button class="pos-btn" onclick="setLogoPos('bc')" title="Bottom Center">↓</button>
|
| 433 |
-
<button class="pos-btn" onclick="setLogoPos('br')" title="Bottom Right">↘</button>
|
| 434 |
-
</div>
|
| 435 |
-
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
|
| 436 |
-
<span style="font-size:.7rem;color:var(--muted);white-space:nowrap">Size</span>
|
| 437 |
-
<input type="range" id="logo-size" min="40" max="300" value="80" style="flex:1;accent-color:var(--amber)" oninput="onLogoSizeChange(this.value)">
|
| 438 |
-
<span id="logo-size-val" style="font-size:.72rem;color:var(--amber2);min-width:32px">80px</span>
|
| 439 |
-
</div>
|
| 440 |
-
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
| 441 |
-
<span style="font-size:.7rem;color:var(--muted);white-space:nowrap;width:10px">X</span>
|
| 442 |
-
<input type="range" id="logo-x-slider" min="0" max="660" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
|
| 443 |
-
<span id="logo-x-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
|
| 444 |
-
</div>
|
| 445 |
-
<div style="display:flex;align-items:center;gap:6px">
|
| 446 |
-
<span style="font-size:.7rem;color:var(--muted);white-space:nowrap;width:10px">Y</span>
|
| 447 |
-
<input type="range" id="logo-y-slider" min="0" max="1200" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
|
| 448 |
-
<span id="logo-y-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
|
| 449 |
-
</div>
|
| 450 |
-
</div>
|
| 451 |
-
</div>
|
| 452 |
-
</div>
|
| 453 |
-
</div>
|
| 454 |
-
</div>
|
| 455 |
</div>
|
| 456 |
|
| 457 |
<div id="action-full">
|
|
@@ -474,9 +388,8 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 474 |
<div class="preview-panel">
|
| 475 |
<div class="preview-box">
|
| 476 |
<div class="preview-top"><i class="fas fa-play-circle"></i> PREVIEW</div>
|
| 477 |
-
<div class="video-wrap"
|
| 478 |
-
<
|
| 479 |
-
<video id="preview-video" controls style="display:none;position:relative;z-index:2"></video>
|
| 480 |
<div class="video-placeholder" id="video-placeholder"><i class="fas fa-film"></i><p>Preview will appear here</p></div>
|
| 481 |
</div>
|
| 482 |
<div class="preview-bottom">
|
|
@@ -484,7 +397,6 @@ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xml
|
|
| 484 |
<div class="meta-tags" id="meta-tags" style="display:none"></div>
|
| 485 |
<button class="download-btn" id="download-btn" onclick="downloadVideo()"><i class="fas fa-download"></i> Download MP4</button>
|
| 486 |
<button class="copy-caption-btn" id="copy-caption-btn" onclick="copyCaption()"><i class="fas fa-copy"></i> Copy Caption</button>
|
| 487 |
-
<button class="copy-caption-btn" id="copy-link-btn" onclick="copyVideoLink()" style="display:none;margin-top:6px"><i class="fas fa-link"></i> Copy Video Link</button>
|
| 488 |
<button class="download-btn" id="download-btn2" onclick="downloadVideo()" style="display:none;margin-top:6px;background:rgba(9,132,227,.06);border-color:rgba(9,132,227,.2);color:var(--cyan)"><i class="fas fa-link"></i> Open Link</button>
|
| 489 |
</div>
|
| 490 |
</div>
|
|
@@ -562,7 +474,6 @@ let IS_ADMIN = false;
|
|
| 562 |
let AUTH_MODE = 'login';
|
| 563 |
let SELECTED_VOICE = 'my-MM-ThihaNeural';
|
| 564 |
let SELECTED_ENGINE = 'ms';
|
| 565 |
-
let VO_LANG = 'my'; // 'my' | 'th' | 'en'
|
| 566 |
let MODE = 'full';
|
| 567 |
let CUR_OUTPUT_URL = '';
|
| 568 |
let CUR_CAPTION = '';
|
|
@@ -573,28 +484,17 @@ let CURRENT_TID = '';
|
|
| 573 |
let SSE_SOURCE = null;
|
| 574 |
|
| 575 |
const MS_V = [
|
| 576 |
-
|
| 577 |
-
{id:'my-MM-
|
| 578 |
-
{id:'
|
| 579 |
-
|
| 580 |
-
{id:'
|
| 581 |
-
{id:'
|
| 582 |
-
{id:'
|
| 583 |
-
|
| 584 |
-
{id:'
|
| 585 |
-
{id:'
|
| 586 |
-
{id:'
|
| 587 |
-
{id:'en-US-AnaNeural', name:'Ana', sub:'မိန်း — ချိုသာ၊ လတ်ဆတ်', lang:'en'},
|
| 588 |
-
{id:'en-US-DavisNeural', name:'Davis', sub:'ကျား — ပျော်ရွှင်၊ တက်ကြွ', lang:'en'},
|
| 589 |
-
{id:'en-US-EmmaNeural', name:'Emma', sub:'မိန်း — နူးညံ့၊ သာယာ', lang:'en'},
|
| 590 |
-
{id:'en-US-JasonNeural', name:'Jason', sub:'ကျား — နက်ရှိုင်း၊ တည်ငြိမ်', lang:'en'},
|
| 591 |
-
{id:'en-US-SaraNeural', name:'Sara', sub:'မိန်း — ကြည်လင်၊ ကျွမ်းကျင်', lang:'en'},
|
| 592 |
-
{id:'en-US-TonyNeural', name:'Tony', sub:'ကျား — တက်ကြွ၊ ထက်မြက်', lang:'en'},
|
| 593 |
-
{id:'en-GB-SoniaNeural', name:'Sonia', sub:'မိန်း (UK) — ယဉ်ကျေး၊ ရှင်းလင်း', lang:'en'},
|
| 594 |
-
{id:'en-GB-RyanNeural', name:'Ryan', sub:'ကျား (UK) — ပြတ်သား၊ ယုံကြည်မှုရှိ',lang:'en'},
|
| 595 |
-
{id:'en-GB-LibbyNeural', name:'Libby', sub:'မိန်း (UK) — ဖော်ရွေ၊ သဘာဝကျ', lang:'en'},
|
| 596 |
-
{id:'en-GB-MaisieNeural', name:'Maisie', sub:'မိန်း (UK) — ချိုသာ၊ လတ်ဆတ်', lang:'en'},
|
| 597 |
-
{id:'en-GB-ThomasNeural', name:'Thomas', sub:'ကျား (UK) — တည်ငြိမ်၊ ကျွမ်းကျင်',lang:'en'},
|
| 598 |
];
|
| 599 |
let GEMINI_V = [];
|
| 600 |
|
|
@@ -671,7 +571,6 @@ function pasteUrl(){
|
|
| 671 |
navigator.clipboard.readText().then(t=>{
|
| 672 |
document.getElementById('video-url').value = t.trim();
|
| 673 |
detectPlatform(t.trim());
|
| 674 |
-
fetchThumbnail(t.trim());
|
| 675 |
}).catch(()=>toast('Clipboard access denied'));
|
| 676 |
}
|
| 677 |
|
|
@@ -691,12 +590,7 @@ function detectPlatform(url){
|
|
| 691 |
|
| 692 |
document.addEventListener('DOMContentLoaded', ()=>{
|
| 693 |
const urlInput = document.getElementById('video-url');
|
| 694 |
-
if(urlInput)
|
| 695 |
-
urlInput.addEventListener('input', e => {
|
| 696 |
-
detectPlatform(e.target.value);
|
| 697 |
-
fetchThumbnail(e.target.value);
|
| 698 |
-
});
|
| 699 |
-
}
|
| 700 |
});
|
| 701 |
|
| 702 |
function hasVideoInput(){
|
|
@@ -712,168 +606,6 @@ function onFileSelect(inp){
|
|
| 712 |
}
|
| 713 |
}
|
| 714 |
function onMusicSelect(inp){ if(inp.files[0]) document.getElementById('music-name').textContent='🎵 '+inp.files[0].name; }
|
| 715 |
-
|
| 716 |
-
// ── LOGO ──
|
| 717 |
-
let LOGO_X = 10, LOGO_Y = 10, LOGO_W = 80, LOGO_ENABLED = false;
|
| 718 |
-
|
| 719 |
-
function toggleLogo(el){
|
| 720 |
-
el.classList.toggle('checked');
|
| 721 |
-
LOGO_ENABLED = el.classList.contains('checked');
|
| 722 |
-
el.querySelector('.check-box').innerHTML = LOGO_ENABLED ? '<i class="fas fa-check"></i>' : '';
|
| 723 |
-
document.getElementById('logo-lbl').textContent = LOGO_ENABLED ? 'On' : 'Off';
|
| 724 |
-
document.getElementById('logo-section').style.display = LOGO_ENABLED ? '' : 'none';
|
| 725 |
-
}
|
| 726 |
-
|
| 727 |
-
function getCropDims(){
|
| 728 |
-
const crop = document.getElementById('crop').value;
|
| 729 |
-
if(crop==='16:9') return {w:1280, h:720};
|
| 730 |
-
if(crop==='1:1') return {w:720, h:720};
|
| 731 |
-
return {w:720, h:1280}; // 9:16 and original
|
| 732 |
-
}
|
| 733 |
-
|
| 734 |
-
function updateLogoPreview(){
|
| 735 |
-
const canvas = document.getElementById('logo-preview-canvas');
|
| 736 |
-
const img = document.getElementById('logo-drag-img');
|
| 737 |
-
if(!img || !img.src) return;
|
| 738 |
-
const dims = getCropDims();
|
| 739 |
-
// Use canvas offsetWidth (reliable after render) — fallback to 90px
|
| 740 |
-
const cw = canvas.offsetWidth || 90;
|
| 741 |
-
const ch = canvas.offsetHeight || Math.round(cw * dims.h / dims.w);
|
| 742 |
-
// Convert video px → canvas px
|
| 743 |
-
const px = Math.round(LOGO_X * cw / dims.w);
|
| 744 |
-
const py = Math.round(LOGO_Y * ch / dims.h);
|
| 745 |
-
const pw = Math.round(LOGO_W * cw / dims.w);
|
| 746 |
-
img.style.left = px + 'px';
|
| 747 |
-
img.style.top = py + 'px';
|
| 748 |
-
img.style.width = pw + 'px';
|
| 749 |
-
}
|
| 750 |
-
|
| 751 |
-
function onLogoSelect(inp){
|
| 752 |
-
if(!inp.files[0]) return;
|
| 753 |
-
document.getElementById('logo-name').textContent = '🖼️ ' + inp.files[0].name;
|
| 754 |
-
const img = document.getElementById('logo-drag-img');
|
| 755 |
-
img.src = URL.createObjectURL(inp.files[0]);
|
| 756 |
-
// Sync bg video
|
| 757 |
-
const pv = document.getElementById('preview-video');
|
| 758 |
-
const bgv = document.getElementById('logo-bg-video');
|
| 759 |
-
if(pv && pv.src) bgv.src = pv.src;
|
| 760 |
-
// Set canvas ratio
|
| 761 |
-
const dims = getCropDims();
|
| 762 |
-
const canvas = document.getElementById('logo-preview-canvas');
|
| 763 |
-
canvas.style.aspectRatio = dims.w+'/'+dims.h;
|
| 764 |
-
// Update slider max
|
| 765 |
-
document.getElementById('logo-x-slider').max = dims.w - LOGO_W;
|
| 766 |
-
document.getElementById('logo-y-slider').max = dims.h - LOGO_W;
|
| 767 |
-
document.getElementById('logo-pos-wrap').style.display = '';
|
| 768 |
-
// Wait for DOM to render canvas before calculating positions
|
| 769 |
-
requestAnimationFrame(()=> requestAnimationFrame(updateLogoPreview));
|
| 770 |
-
}
|
| 771 |
-
|
| 772 |
-
function onLogoSizeChange(val){
|
| 773 |
-
LOGO_W = parseInt(val);
|
| 774 |
-
document.getElementById('logo-size-val').textContent = val + 'px';
|
| 775 |
-
const dims = getCropDims();
|
| 776 |
-
document.getElementById('logo-x-slider').max = dims.w - LOGO_W;
|
| 777 |
-
document.getElementById('logo-y-slider').max = dims.h - LOGO_W;
|
| 778 |
-
updateLogoPreview();
|
| 779 |
-
}
|
| 780 |
-
|
| 781 |
-
function onLogoPosSlider(){
|
| 782 |
-
LOGO_X = parseInt(document.getElementById('logo-x-slider').value);
|
| 783 |
-
LOGO_Y = parseInt(document.getElementById('logo-y-slider').value);
|
| 784 |
-
document.getElementById('logo-x-val').textContent = LOGO_X;
|
| 785 |
-
document.getElementById('logo-y-val').textContent = LOGO_Y;
|
| 786 |
-
// Clear active grid btn
|
| 787 |
-
document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
|
| 788 |
-
updateLogoPreview();
|
| 789 |
-
}
|
| 790 |
-
|
| 791 |
-
function setLogoPos(pos){
|
| 792 |
-
const dims = getCropDims();
|
| 793 |
-
const pad = 20;
|
| 794 |
-
const positions = {
|
| 795 |
-
tl:{x:pad, y:pad},
|
| 796 |
-
tc:{x:(dims.w-LOGO_W)/2, y:pad},
|
| 797 |
-
tr:{x:dims.w-LOGO_W-pad, y:pad},
|
| 798 |
-
ml:{x:pad, y:(dims.h-LOGO_W)/2},
|
| 799 |
-
mc:{x:(dims.w-LOGO_W)/2, y:(dims.h-LOGO_W)/2},
|
| 800 |
-
mr:{x:dims.w-LOGO_W-pad, y:(dims.h-LOGO_W)/2},
|
| 801 |
-
bl:{x:pad, y:dims.h-LOGO_W-pad},
|
| 802 |
-
bc:{x:(dims.w-LOGO_W)/2, y:dims.h-LOGO_W-pad},
|
| 803 |
-
br:{x:dims.w-LOGO_W-pad, y:dims.h-LOGO_W-pad},
|
| 804 |
-
};
|
| 805 |
-
const p = positions[pos];
|
| 806 |
-
LOGO_X = Math.max(0, Math.round(p.x));
|
| 807 |
-
LOGO_Y = Math.max(0, Math.round(p.y));
|
| 808 |
-
document.getElementById('logo-x-slider').value = LOGO_X;
|
| 809 |
-
document.getElementById('logo-y-slider').value = LOGO_Y;
|
| 810 |
-
document.getElementById('logo-x-val').textContent = LOGO_X;
|
| 811 |
-
document.getElementById('logo-y-val').textContent = LOGO_Y;
|
| 812 |
-
// Highlight active btn
|
| 813 |
-
document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
|
| 814 |
-
event.target.classList.add('active');
|
| 815 |
-
updateLogoPreview();
|
| 816 |
-
}
|
| 817 |
-
|
| 818 |
-
// Update canvas ratio when crop changes
|
| 819 |
-
document.addEventListener('DOMContentLoaded', ()=>{
|
| 820 |
-
const cropSel = document.getElementById('crop');
|
| 821 |
-
if(cropSel) cropSel.addEventListener('change', ()=>{
|
| 822 |
-
const dims = getCropDims();
|
| 823 |
-
const canvas = document.getElementById('logo-preview-canvas');
|
| 824 |
-
if(canvas) canvas.style.aspectRatio = dims.w+'/'+dims.h;
|
| 825 |
-
document.getElementById('logo-x-slider').max = dims.w - LOGO_W;
|
| 826 |
-
document.getElementById('logo-y-slider').max = dims.h - LOGO_W;
|
| 827 |
-
updateLogoPreview();
|
| 828 |
-
});
|
| 829 |
-
});
|
| 830 |
-
|
| 831 |
-
// ── THUMBNAIL FETCH ──
|
| 832 |
-
async function fetchThumbnail(url){
|
| 833 |
-
if(!url || !url.startsWith('http')) return;
|
| 834 |
-
const thumb = document.getElementById('thumb-preview');
|
| 835 |
-
|
| 836 |
-
// YouTube
|
| 837 |
-
const yt = url.match(/(?:youtu\.be\/|[?&]v=|shorts\/)([A-Za-z0-9_-]{11})/);
|
| 838 |
-
if(yt){
|
| 839 |
-
thumb.src = `https://img.youtube.com/vi/${yt[1]}/hqdefault.jpg`;
|
| 840 |
-
thumb.style.display='block';
|
| 841 |
-
document.getElementById('video-placeholder').style.display='none';
|
| 842 |
-
return;
|
| 843 |
-
}
|
| 844 |
-
|
| 845 |
-
// TikTok — oEmbed
|
| 846 |
-
if(/tiktok\.com/i.test(url)){
|
| 847 |
-
try{
|
| 848 |
-
const r = await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`);
|
| 849 |
-
const d = await r.json();
|
| 850 |
-
if(d.thumbnail_url){
|
| 851 |
-
thumb.src = d.thumbnail_url;
|
| 852 |
-
thumb.style.display='block';
|
| 853 |
-
document.getElementById('video-placeholder').style.display='none';
|
| 854 |
-
return;
|
| 855 |
-
}
|
| 856 |
-
}catch(e){}
|
| 857 |
-
}
|
| 858 |
-
|
| 859 |
-
// Facebook — show placeholder icon (FB blocks direct thumbnail access)
|
| 860 |
-
if(/facebook\.com|fb\.watch/i.test(url)){
|
| 861 |
-
thumb.style.display='none';
|
| 862 |
-
const ph = document.getElementById('video-placeholder');
|
| 863 |
-
ph.style.display='flex';
|
| 864 |
-
ph.innerHTML='<i class="fab fa-facebook" style="font-size:2.5rem;color:#1877f2"></i><p style="font-size:.8rem">Facebook Video</p>';
|
| 865 |
-
return;
|
| 866 |
-
}
|
| 867 |
-
|
| 868 |
-
// Instagram — show placeholder icon
|
| 869 |
-
if(/instagram\.com/i.test(url)){
|
| 870 |
-
thumb.style.display='none';
|
| 871 |
-
const ph = document.getElementById('video-placeholder');
|
| 872 |
-
ph.style.display='flex';
|
| 873 |
-
ph.innerHTML='<i class="fab fa-instagram" style="font-size:2.5rem;color:#e1306c"></i><p style="font-size:.8rem">Instagram Video</p>';
|
| 874 |
-
return;
|
| 875 |
-
}
|
| 876 |
-
}
|
| 877 |
function dragOver(e){ e.preventDefault(); document.getElementById('upload-area').classList.add('drag'); }
|
| 878 |
function dragLeave(){ document.getElementById('upload-area').classList.remove('drag'); }
|
| 879 |
function dropFile(e){
|
|
@@ -892,36 +624,6 @@ function toggleSpeed(){
|
|
| 892 |
document.getElementById('speed-row').classList.toggle('visible');
|
| 893 |
}
|
| 894 |
|
| 895 |
-
// Default voice + speed per language
|
| 896 |
-
const LANG_DEFAULT_VOICE = {
|
| 897 |
-
my: {id:'my-MM-ThihaNeural', engine:'ms'},
|
| 898 |
-
th: {id:'th-TH-PremwadeeNeural', engine:'ms'},
|
| 899 |
-
en: {id:'en-US-AriaNeural', engine:'ms'},
|
| 900 |
-
};
|
| 901 |
-
const LANG_DEFAULT_SPEED = { my: 30, th: 20, en: 0 };
|
| 902 |
-
|
| 903 |
-
function switchLang(lang){
|
| 904 |
-
VO_LANG = lang;
|
| 905 |
-
['my','th','en'].forEach(l => document.getElementById('lang-'+l).classList.toggle('active', l===lang));
|
| 906 |
-
// Auto-set speed
|
| 907 |
-
const spd = LANG_DEFAULT_SPEED[lang] ?? 30;
|
| 908 |
-
const slider = document.getElementById('speed-slider');
|
| 909 |
-
const valEl = document.getElementById('speed-val');
|
| 910 |
-
if(slider){ slider.value = spd; valEl.textContent = spd+'%'; }
|
| 911 |
-
// Auto-select default voice for this language (MS tab)
|
| 912 |
-
if(VCAT === 'ms' || lang !== 'my'){
|
| 913 |
-
if(lang !== 'my'){
|
| 914 |
-
VCAT = 'ms'; SELECTED_ENGINE = 'ms';
|
| 915 |
-
document.getElementById('vcat-ms').classList.add('active');
|
| 916 |
-
document.getElementById('vcat-g').classList.remove('active');
|
| 917 |
-
}
|
| 918 |
-
const def = LANG_DEFAULT_VOICE[lang];
|
| 919 |
-
SELECTED_VOICE = def.id;
|
| 920 |
-
SELECTED_ENGINE = def.engine;
|
| 921 |
-
}
|
| 922 |
-
renderVoices(VCAT);
|
| 923 |
-
}
|
| 924 |
-
|
| 925 |
function switchVCat(c){
|
| 926 |
VCAT = c;
|
| 927 |
document.getElementById('vcat-ms').classList.toggle('active', c==='ms');
|
|
@@ -929,72 +631,27 @@ function switchVCat(c){
|
|
| 929 |
SELECTED_ENGINE = c==='g' ? 'gemini' : 'ms';
|
| 930 |
if(c==='g' && GEMINI_V.length===0){
|
| 931 |
fetch('/api/gemini_voices').then(r=>r.json()).then(d=>{
|
| 932 |
-
if(d.ok) d.voices.forEach(v=>GEMINI_V.push({id:v.id,name:v.name,sub:''
|
| 933 |
renderVoices('g');
|
| 934 |
});
|
| 935 |
} else renderVoices(c);
|
| 936 |
}
|
| 937 |
|
| 938 |
function renderVoices(cat){
|
|
|
|
| 939 |
const grid = document.getElementById('voice-grid');
|
| 940 |
const q = document.getElementById('voice-search').value.toLowerCase();
|
| 941 |
-
|
| 942 |
-
if(cat === 'g'){
|
| 943 |
-
voices = GEMINI_V;
|
| 944 |
-
} else {
|
| 945 |
-
voices = MS_V.filter(v => v.lang === VO_LANG);
|
| 946 |
-
}
|
| 947 |
-
const filtered = voices.filter(v=>
|
| 948 |
-
v.name.toLowerCase().includes(q) ||
|
| 949 |
-
v.id.toLowerCase().includes(q) ||
|
| 950 |
-
(v.sub||'').toLowerCase().includes(q)
|
| 951 |
-
);
|
| 952 |
-
const isMS = cat !== 'g';
|
| 953 |
grid.innerHTML = filtered.map(v=>`
|
| 954 |
-
<div class="vcard ${SELECTED_VOICE===v.id?'selected':''}" onclick="selectVoice('${v.id}',
|
| 955 |
<div class="vcard-name">${v.name}</div>
|
| 956 |
-
<div class="vcard-sub">${v.sub
|
| 957 |
-
${isMS ? `<div class="vcard-play" id="play-${v.id.replace(/[^a-z0-9]/gi,'_')}" onclick="event.stopPropagation();previewVoice('${v.id}',this)"><i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်</div>` : ''}
|
| 958 |
</div>`).join('');
|
| 959 |
}
|
| 960 |
|
| 961 |
function filterVoices(q){ renderVoices(VCAT); }
|
| 962 |
-
|
| 963 |
-
let _previewAudio = null;
|
| 964 |
-
async function previewVoice(voiceId, btnEl){
|
| 965 |
-
// Stop any playing preview
|
| 966 |
-
if(_previewAudio){ _previewAudio.pause(); _previewAudio = null; }
|
| 967 |
-
document.querySelectorAll('.vcard-play').forEach(b=>{
|
| 968 |
-
b.classList.remove('playing');
|
| 969 |
-
b.innerHTML = '<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်';
|
| 970 |
-
});
|
| 971 |
-
btnEl.classList.add('playing');
|
| 972 |
-
btnEl.innerHTML = '<i class="fas fa-spinner spinning" style="font-size:.55rem"></i> Loading…';
|
| 973 |
-
try {
|
| 974 |
-
const spd = document.getElementById('speed-slider').value;
|
| 975 |
-
const r = await fetch('/api/preview_voice', {
|
| 976 |
-
method:'POST', headers:{'Content-Type':'application/json'},
|
| 977 |
-
body: JSON.stringify({voice: voiceId, speed: parseInt(spd), engine:'ms'})
|
| 978 |
-
});
|
| 979 |
-
const d = await r.json();
|
| 980 |
-
if(!d.ok){ toast('❌ Preview failed'); btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; return; }
|
| 981 |
-
_previewAudio = new Audio(d.url);
|
| 982 |
-
_previewAudio.play();
|
| 983 |
-
btnEl.innerHTML = '<i class="fas fa-volume-up" style="font-size:.55rem"></i> ဖွင့်နေ…';
|
| 984 |
-
_previewAudio.onended = ()=>{
|
| 985 |
-
btnEl.classList.remove('playing');
|
| 986 |
-
btnEl.innerHTML = '<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်';
|
| 987 |
-
_previewAudio = null;
|
| 988 |
-
};
|
| 989 |
-
} catch(e){
|
| 990 |
-
toast('❌ '+e);
|
| 991 |
-
btnEl.classList.remove('playing');
|
| 992 |
-
btnEl.innerHTML = '<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်';
|
| 993 |
-
}
|
| 994 |
-
}
|
| 995 |
-
function selectVoice(id, lang, el){
|
| 996 |
SELECTED_VOICE = id;
|
| 997 |
-
if(lang) VO_LANG = lang;
|
| 998 |
document.querySelectorAll('.vcard').forEach(c=>c.classList.remove('selected'));
|
| 999 |
el.classList.add('selected');
|
| 1000 |
}
|
|
@@ -1015,7 +672,6 @@ function buildFormData(includeScript){
|
|
| 1015 |
fd.append('username', CUR_USER);
|
| 1016 |
fd.append('voice', SELECTED_VOICE);
|
| 1017 |
fd.append('engine', SELECTED_ENGINE);
|
| 1018 |
-
fd.append('vo_lang', VO_LANG);
|
| 1019 |
fd.append('speed', document.getElementById('speed-slider').value);
|
| 1020 |
fd.append('crop', document.getElementById('crop').value);
|
| 1021 |
fd.append('flip', isChecked('chk-fl') ? '1' : '0');
|
|
@@ -1032,16 +688,6 @@ function buildFormData(includeScript){
|
|
| 1032 |
}
|
| 1033 |
const mf = document.getElementById('music-file').files[0];
|
| 1034 |
if(mf) fd.append('music_file', mf);
|
| 1035 |
-
// Logo — only if enabled
|
| 1036 |
-
if(LOGO_ENABLED){
|
| 1037 |
-
const lf = document.getElementById('logo-file').files[0];
|
| 1038 |
-
if(lf){
|
| 1039 |
-
fd.append('logo_file', lf);
|
| 1040 |
-
fd.append('logo_x', LOGO_X);
|
| 1041 |
-
fd.append('logo_y', LOGO_Y);
|
| 1042 |
-
fd.append('logo_w', LOGO_W);
|
| 1043 |
-
}
|
| 1044 |
-
}
|
| 1045 |
if(includeScript){
|
| 1046 |
const sc = document.getElementById('script-in').value.trim();
|
| 1047 |
if(sc) fd.append('script', sc);
|
|
@@ -1057,7 +703,7 @@ async function doProcessAll(){
|
|
| 1057 |
fd.append('tid', CURRENT_TID);
|
| 1058 |
try {
|
| 1059 |
const ctrl = new AbortController();
|
| 1060 |
-
const timer = setTimeout(()=>ctrl.abort(),
|
| 1061 |
const r = await fetch('/api/process_all',{method:'POST',body:fd,signal:ctrl.signal});
|
| 1062 |
clearTimeout(timer);
|
| 1063 |
const d = await r.json();
|
|
@@ -1137,10 +783,6 @@ function showPreviewVideo(url){
|
|
| 1137 |
const v = document.getElementById('preview-video');
|
| 1138 |
v.src = url; v.style.display='block';
|
| 1139 |
document.getElementById('video-placeholder').style.display='none';
|
| 1140 |
-
document.getElementById('thumb-preview').style.display='none';
|
| 1141 |
-
// Sync logo bg video if logo is set
|
| 1142 |
-
const bgv = document.getElementById('logo-bg-video');
|
| 1143 |
-
if(bgv) bgv.src = url;
|
| 1144 |
}
|
| 1145 |
|
| 1146 |
function downloadVideo(){
|
|
|
|
| 95 |
.vcard:hover{border-color:var(--border2);background:var(--bg2)}
|
| 96 |
.vcard.selected{border-color:var(--amber);background:rgba(245,166,35,.06)}
|
| 97 |
.vcard-name{font-size:.72rem;font-weight:600;margin-bottom:2px;line-height:1.2}
|
| 98 |
+
.vcard-sub{font-size:.6rem;color:var(--muted);margin-bottom:6px}
|
| 99 |
+
.vcard-play{display:none !important}
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
/* ── SPEED (hidden until toggle) ── */
|
| 102 |
.speed-toggle{display:flex;align-items:center;gap:6px;margin-top:10px;cursor:pointer;font-size:.78rem;color:var(--muted2);font-weight:500;user-select:none}
|
|
|
|
| 148 |
.preview-top{padding:12px 14px;border-bottom:1px solid var(--border);font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
|
| 149 |
.preview-top i{color:var(--amber)}
|
| 150 |
.video-wrap{aspect-ratio:9/16;background:#f0f1f4;position:relative;max-height:400px;overflow:hidden}
|
| 151 |
+
.video-wrap video{width:100%;height:100%;object-fit:contain}
|
| 152 |
.video-placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted)}
|
| 153 |
.video-placeholder i{font-size:2.5rem}
|
| 154 |
.video-placeholder p{font-size:.8rem}
|
|
|
|
| 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>
|
|
|
|
| 326 |
<!-- SETTINGS -->
|
| 327 |
<div class="card">
|
| 328 |
<div class="card-label"><i class="fas fa-cog"></i> SETTINGS</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
<div class="grid2">
|
| 330 |
<div>
|
| 331 |
<div class="field-label"><i class="fas fa-crop-alt"></i> Crop Ratio</div>
|
|
|
|
| 366 |
<span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-music"></i> <span id="music-name">Choose MP3</span></span>
|
| 367 |
</div>
|
| 368 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
</div>
|
| 370 |
|
| 371 |
<div id="action-full">
|
|
|
|
| 388 |
<div class="preview-panel">
|
| 389 |
<div class="preview-box">
|
| 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">
|
|
|
|
| 397 |
<div class="meta-tags" id="meta-tags" style="display:none"></div>
|
| 398 |
<button class="download-btn" id="download-btn" onclick="downloadVideo()"><i class="fas fa-download"></i> Download MP4</button>
|
| 399 |
<button class="copy-caption-btn" id="copy-caption-btn" onclick="copyCaption()"><i class="fas fa-copy"></i> Copy Caption</button>
|
|
|
|
| 400 |
<button class="download-btn" id="download-btn2" onclick="downloadVideo()" style="display:none;margin-top:6px;background:rgba(9,132,227,.06);border-color:rgba(9,132,227,.2);color:var(--cyan)"><i class="fas fa-link"></i> Open Link</button>
|
| 401 |
</div>
|
| 402 |
</div>
|
|
|
|
| 474 |
let AUTH_MODE = 'login';
|
| 475 |
let SELECTED_VOICE = 'my-MM-ThihaNeural';
|
| 476 |
let SELECTED_ENGINE = 'ms';
|
|
|
|
| 477 |
let MODE = 'full';
|
| 478 |
let CUR_OUTPUT_URL = '';
|
| 479 |
let CUR_CAPTION = '';
|
|
|
|
| 484 |
let SSE_SOURCE = null;
|
| 485 |
|
| 486 |
const MS_V = [
|
| 487 |
+
{id:'my-MM-ThihaNeural',name:'Thiha',sub:'Male'},
|
| 488 |
+
{id:'my-MM-NilarNeural',name:'Nilar',sub:'Female'},
|
| 489 |
+
{id:'en-US-AriaNeural',name:'Aria',sub:'EN Female'},
|
| 490 |
+
{id:'en-US-GuyNeural',name:'Guy',sub:'EN Male'},
|
| 491 |
+
{id:'en-US-JennyNeural',name:'Jenny',sub:'EN Female'},
|
| 492 |
+
{id:'en-GB-SoniaNeural',name:'Sonia',sub:'UK Female'},
|
| 493 |
+
{id:'en-GB-RyanNeural',name:'Ryan',sub:'UK Male'},
|
| 494 |
+
{id:'zh-CN-XiaoxiaoNeural',name:'Xiaoxiao',sub:'CN Female'},
|
| 495 |
+
{id:'ja-JP-NanamiNeural',name:'Nanami',sub:'JP Female'},
|
| 496 |
+
{id:'ko-KR-SunHiNeural',name:'SunHi',sub:'KR Female'},
|
| 497 |
+
{id:'th-TH-PremwadeeNeural',name:'Premwadee',sub:'TH Female'},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
];
|
| 499 |
let GEMINI_V = [];
|
| 500 |
|
|
|
|
| 571 |
navigator.clipboard.readText().then(t=>{
|
| 572 |
document.getElementById('video-url').value = t.trim();
|
| 573 |
detectPlatform(t.trim());
|
|
|
|
| 574 |
}).catch(()=>toast('Clipboard access denied'));
|
| 575 |
}
|
| 576 |
|
|
|
|
| 590 |
|
| 591 |
document.addEventListener('DOMContentLoaded', ()=>{
|
| 592 |
const urlInput = document.getElementById('video-url');
|
| 593 |
+
if(urlInput) urlInput.addEventListener('input', e => detectPlatform(e.target.value));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
});
|
| 595 |
|
| 596 |
function hasVideoInput(){
|
|
|
|
| 606 |
}
|
| 607 |
}
|
| 608 |
function onMusicSelect(inp){ if(inp.files[0]) document.getElementById('music-name').textContent='🎵 '+inp.files[0].name; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
function dragOver(e){ e.preventDefault(); document.getElementById('upload-area').classList.add('drag'); }
|
| 610 |
function dragLeave(){ document.getElementById('upload-area').classList.remove('drag'); }
|
| 611 |
function dropFile(e){
|
|
|
|
| 624 |
document.getElementById('speed-row').classList.toggle('visible');
|
| 625 |
}
|
| 626 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
function switchVCat(c){
|
| 628 |
VCAT = c;
|
| 629 |
document.getElementById('vcat-ms').classList.toggle('active', c==='ms');
|
|
|
|
| 631 |
SELECTED_ENGINE = c==='g' ? 'gemini' : 'ms';
|
| 632 |
if(c==='g' && GEMINI_V.length===0){
|
| 633 |
fetch('/api/gemini_voices').then(r=>r.json()).then(d=>{
|
| 634 |
+
if(d.ok) d.voices.forEach(v=>GEMINI_V.push({id:v.id,name:v.name,sub:''}));
|
| 635 |
renderVoices('g');
|
| 636 |
});
|
| 637 |
} else renderVoices(c);
|
| 638 |
}
|
| 639 |
|
| 640 |
function renderVoices(cat){
|
| 641 |
+
const voices = cat==='g' ? GEMINI_V : MS_V;
|
| 642 |
const grid = document.getElementById('voice-grid');
|
| 643 |
const q = document.getElementById('voice-search').value.toLowerCase();
|
| 644 |
+
const filtered = voices.filter(v=>v.name.toLowerCase().includes(q)||v.id.toLowerCase().includes(q));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
grid.innerHTML = filtered.map(v=>`
|
| 646 |
+
<div class="vcard ${SELECTED_VOICE===v.id?'selected':''}" onclick="selectVoice('${v.id}',this)">
|
| 647 |
<div class="vcard-name">${v.name}</div>
|
| 648 |
+
<div class="vcard-sub">${v.sub}</div>
|
|
|
|
| 649 |
</div>`).join('');
|
| 650 |
}
|
| 651 |
|
| 652 |
function filterVoices(q){ renderVoices(VCAT); }
|
| 653 |
+
function selectVoice(id, el){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
SELECTED_VOICE = id;
|
|
|
|
| 655 |
document.querySelectorAll('.vcard').forEach(c=>c.classList.remove('selected'));
|
| 656 |
el.classList.add('selected');
|
| 657 |
}
|
|
|
|
| 672 |
fd.append('username', CUR_USER);
|
| 673 |
fd.append('voice', SELECTED_VOICE);
|
| 674 |
fd.append('engine', SELECTED_ENGINE);
|
|
|
|
| 675 |
fd.append('speed', document.getElementById('speed-slider').value);
|
| 676 |
fd.append('crop', document.getElementById('crop').value);
|
| 677 |
fd.append('flip', isChecked('chk-fl') ? '1' : '0');
|
|
|
|
| 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);
|
|
|
|
| 703 |
fd.append('tid', CURRENT_TID);
|
| 704 |
try {
|
| 705 |
const ctrl = new AbortController();
|
| 706 |
+
const timer = setTimeout(()=>ctrl.abort(), 600000);
|
| 707 |
const r = await fetch('/api/process_all',{method:'POST',body:fd,signal:ctrl.signal});
|
| 708 |
clearTimeout(timer);
|
| 709 |
const d = await r.json();
|
|
|
|
| 783 |
const v = document.getElementById('preview-video');
|
| 784 |
v.src = url; v.style.display='block';
|
| 785 |
document.getElementById('video-placeholder').style.display='none';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
}
|
| 787 |
|
| 788 |
function downloadVideo(){
|
m_youtube_com_cookies.txt
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
# Netscape HTTP Cookie File
|
| 2 |
-
#
|
| 3 |
-
# This is a generated file! Do not edit.
|
| 4 |
|
| 5 |
.youtube.com TRUE / FALSE 1807419670 HSID AbIQP2u9sl_Bnx0g5
|
| 6 |
.youtube.com TRUE / TRUE 1807419670 SSID ARUUfbNPh92AKO5_p
|
|
@@ -11,10 +10,18 @@
|
|
| 11 |
.youtube.com TRUE / FALSE 1807419670 SID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrZxi6iorwIQ3xtfFXcSuVXwACgYKAQwSARASFQHGX2MiVxcnmRJO7uOlwy3PxCDXGhoVAUF8yKpHQUl7lgYfpi0qGObLsX5H0076
|
| 12 |
.youtube.com TRUE / TRUE 1807419670 __Secure-1PSID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrQVE4_qQpYPr784enWmbf7wACgYKAbcSARASFQHGX2MiSMcqGdcCalYjuRGFq5LbVBoVAUF8yKqVQF-sliTQkRIyb1XDzw5X0076
|
| 13 |
.youtube.com TRUE / TRUE 1807419670 __Secure-3PSID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrzqOS36tWU0CIXs3cerrxeAACgYKAe4SARASFQHGX2Mi7VHrSfHLUV6tErWLzpV4nBoVAUF8yKrHu6T_gUNfkXQSwf0xQ5Il0076
|
| 14 |
-
.youtube.com TRUE /
|
| 15 |
.youtube.com TRUE / TRUE 1804395670 __Secure-1PSIDTS sidts-CjUBBj1CYjBzrPiWsjOc3UdOv7GeV3lhd7EW9D_OutE1KkfvsmhjH4FnEkdDEtzcYGrD_o-GvBAA
|
| 16 |
.youtube.com TRUE / TRUE 1804395670 __Secure-3PSIDTS sidts-CjUBBj1CYjBzrPiWsjOc3UdOv7GeV3lhd7EW9D_OutE1KkfvsmhjH4FnEkdDEtzcYGrD_o-GvBAA
|
| 17 |
.youtube.com TRUE / TRUE 1807419670 LOGIN_INFO AFmmF2swRQIhAJZITc_-ARiB1DrpXF_oTjarOxRospGJWcB_aSYLHSFDAiA9SlGWqEXXI_scn8UYzqO1-WDrV9HYqH9cjNixTgOydA:QUQ3MjNmeWNJNTUxeE83czJKelU2WnR3MUxTbE96OV96VVdUUm5BSHFkX0JWRWczMm5UVzNMaE5nQ3Nrd01nMmJNOV9paVlQTlV5ZG5WRnQ5WjJUdWlpM0lUbDdRYmJ3Z2NvVHNkU1BRQ3l5NlR0Rm5yWFI3NjhQOWotLVJGdDc4aUN3THA4UENjMUJBc1c1Um1GRWRMVzROb1VXSmNLVUFn
|
| 18 |
-
.youtube.com TRUE / FALSE
|
| 19 |
-
.youtube.com TRUE / TRUE
|
| 20 |
-
.youtube.com TRUE / TRUE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Netscape HTTP Cookie File
|
| 2 |
+
# This file is generated by yt-dlp. Do not edit.
|
|
|
|
| 3 |
|
| 4 |
.youtube.com TRUE / FALSE 1807419670 HSID AbIQP2u9sl_Bnx0g5
|
| 5 |
.youtube.com TRUE / TRUE 1807419670 SSID ARUUfbNPh92AKO5_p
|
|
|
|
| 10 |
.youtube.com TRUE / FALSE 1807419670 SID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrZxi6iorwIQ3xtfFXcSuVXwACgYKAQwSARASFQHGX2MiVxcnmRJO7uOlwy3PxCDXGhoVAUF8yKpHQUl7lgYfpi0qGObLsX5H0076
|
| 11 |
.youtube.com TRUE / TRUE 1807419670 __Secure-1PSID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrQVE4_qQpYPr784enWmbf7wACgYKAbcSARASFQHGX2MiSMcqGdcCalYjuRGFq5LbVBoVAUF8yKqVQF-sliTQkRIyb1XDzw5X0076
|
| 12 |
.youtube.com TRUE / TRUE 1807419670 __Secure-3PSID g.a0007ghOD_dtmZYRBcsFCcLlSjCZt58NwNnryfaNOHad_EUw9JsrzqOS36tWU0CIXs3cerrxeAACgYKAe4SARASFQHGX2Mi7VHrSfHLUV6tErWLzpV4nBoVAUF8yKrHu6T_gUNfkXQSwf0xQ5Il0076
|
| 13 |
+
.youtube.com TRUE / FALSE 0 PREF f6=40000000&tz=UTC&f4=4000000&hl=en
|
| 14 |
.youtube.com TRUE / TRUE 1804395670 __Secure-1PSIDTS sidts-CjUBBj1CYjBzrPiWsjOc3UdOv7GeV3lhd7EW9D_OutE1KkfvsmhjH4FnEkdDEtzcYGrD_o-GvBAA
|
| 15 |
.youtube.com TRUE / TRUE 1804395670 __Secure-3PSIDTS sidts-CjUBBj1CYjBzrPiWsjOc3UdOv7GeV3lhd7EW9D_OutE1KkfvsmhjH4FnEkdDEtzcYGrD_o-GvBAA
|
| 16 |
.youtube.com TRUE / TRUE 1807419670 LOGIN_INFO AFmmF2swRQIhAJZITc_-ARiB1DrpXF_oTjarOxRospGJWcB_aSYLHSFDAiA9SlGWqEXXI_scn8UYzqO1-WDrV9HYqH9cjNixTgOydA:QUQ3MjNmeWNJNTUxeE83czJKelU2WnR3MUxTbE96OV96VVdUUm5BSHFkX0JWRWczMm5UVzNMaE5nQ3Nrd01nMmJNOV9paVlQTlV5ZG5WRnQ5WjJUdWlpM0lUbDdRYmJ3Z2NvVHNkU1BRQ3l5NlR0Rm5yWFI3NjhQOWotLVJGdDc4aUN3THA4UENjMUJBc1c1Um1GRWRMVzROb1VXSmNLVUFn
|
| 17 |
+
.youtube.com TRUE / FALSE 1805100407 SIDCC AKEyXzUZzXYE9q1sGPyrjTzRU7or3ztQoOWmWUjpNpD4KWkkRjVg90OhszsHFH3vA_z-wIJp0g
|
| 18 |
+
.youtube.com TRUE / TRUE 1805100407 __Secure-1PSIDCC AKEyXzU7YqlW3ainCEwhll9c3ugmwBmlzZ2BEuUHW3XBI7wfpBub3H4EvE7MGoneo0d66sTcDg
|
| 19 |
+
.youtube.com TRUE / TRUE 1805100407 __Secure-3PSIDCC AKEyXzWw9LKhoM1BEWg7Qe4CFaLhowWakCCvZMu_UwyjJGzPlpCrzK2VC7C-4KRQ6atHU8mR
|
| 20 |
+
.youtube.com TRUE / TRUE 1807673124 __Secure-YENID 14.YTE=ktodmcvCvot2KhGLrHdMFIH43XujYTwrvpNFERZRqCL9vzfb6ocOX7XbgDhWdGX_4XW7W17zfAQqERP2mdJ10BSnyTvEjz0DGFmNN9mbg7pE6s9pwn7B_PGnIDkPzVKbBAWg2tOks6U1HvG6JLUCDTdjGzEd3G9IEOs-ec-wvLUDwdNzyrN19BjNPpOGK98fSgKmnRs7jLYv6t4HpF-XXegyORmjlUjPLfFwKyZbOps24BJZZI3fZZl2aIzA4KCWptYWK28q-fvohFQktrpf-gQ0XRfRlvL8TFBGaEivG0Y-e-S62HJMVPmvOicZ_cBKkko1qDjNfm-mibsIYGMrRw
|
| 21 |
+
.youtube.com TRUE / TRUE 0 YSC plPg_tapgO8
|
| 22 |
+
.youtube.com TRUE / TRUE 1807673123 __Secure-YEC CgtFczM2T1MtZnExQSj349nNBjIKCgJERRIEEgAgRmLgAgrdAjE0LllURT1rdG9kbWN2Q3ZvdDJLaEdMckhkTUZJSDQzWHVqWVR3cnZwTkZFUlpScUNMOXZ6ZmI2b2NPWDdYYmdEaFdkR1hfNFhXN1cxN3pmQVFxRVJQMm1kSjEwQlNueVR2RWp6MERHRm1OTjltYmc3cEU2czlwd243Ql9QR25JRGtQelZLYkJBV2cydE9rczZVMUh2RzZKTFVDRFRkakd6RWQzRzlJRU9zLWVjLXd2TFVEd2ROenlyTjE5QmpOUHBPR0s5OGZTZ0ttblJzN2pMWXY2dDRIcEYtWFhlZ3lPUm1qbFVqUExmRndLeVpiT3BzMjRCSlpaSTNmWlpsMmFJekE0S0NXcHRZV0syOHEtZnZvaEZRa3RycGYtZ1EwWFJmUmx2TDhURkJHYUVpdkcwWS1lLVM2MkhKTVZQbXZPaWNaX2NCS2trbzFxRGpOZm0tbWlic0lZR01yUnc%3D
|
| 23 |
+
.youtube.com TRUE / TRUE 1807692407 VISITOR_PRIVACY_METADATA CgJERRIEEgAgRg%3D%3D
|
| 24 |
+
.youtube.com TRUE / TRUE 1789097604 __Secure-ROLLOUT_TOKEN CNTe-8e85Jn_UhCH8_C4-qCTAxj0w96d_KCTAw%3D%3D
|
| 25 |
+
.tiktok.com TRUE / TRUE 1804684903 ttwid 1%7ConRsRaYlTEnh2HeZGgD-iXMNUyODIy3OiTvoUcZY6-k%7C1773580903%7C47fc4402a79fa31a5885a8bdd1f706c6ca7126f1b387eb59fc48c4b42fcb54e6
|
| 26 |
+
.tiktok.com TRUE / TRUE 0 tt_csrf_token BVfV0iS0-5PUImEsPdknbJLmIUh5IvqyE2VA
|
| 27 |
+
.tiktok.com TRUE / TRUE 1789136617 tt_chain_token 9sXx+RxqzEtBf+kc7+7XuA==
|