Opera8 commited on
Commit
12178cf
·
verified ·
1 Parent(s): 26b5f6e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -267
app.py CHANGED
@@ -20,7 +20,7 @@ import google.generativeai as genai
20
  from pydantic import BaseModel
21
  from PIL import Image, ImageDraw, ImageFont
22
  from pydub import AudioSegment
23
- from gradio_client import Client, handle_file
24
 
25
  app = FastAPI()
26
 
@@ -61,10 +61,9 @@ FONT_FILES_MAP = {
61
  "hasti": "Hasti.ttf",
62
  }
63
 
64
- # --- تنظیمات اتصال به اسپیس گِرادیو ویسپر و سرور جدید ---
65
- GRADIO_SPACE_URL = os.getenv("GRADIO_SPACE_URL", "Opera8/sadabematn")
66
- HF_TOKEN = os.getenv("HF_TOKEN", None)
67
- NEW_SPACE_URL = os.getenv("NEW_SPACE_URL", "https://opera8-tts3.hf.space")
68
 
69
  # --- سیستم صف و پایش موقت آپلود غیرهمگام ---
70
  class UploadJobStatus:
@@ -111,7 +110,7 @@ def load_all_styles():
111
 
112
  load_all_styles()
113
 
114
- # تنظیم همزمانی بسیار بالا (ارسال همزمان تمامی قطعات برش‌خورده در یک مرحله)
115
  CONCURRENCY_LIMIT = 20
116
  GEMINI_SEMAPHORE = asyncio.Semaphore(CONCURRENCY_LIMIT)
117
 
@@ -185,7 +184,6 @@ async def queue_worker():
185
  job.error_message = str(e)
186
  render_queue.task_done()
187
 
188
- # کارگر پس‌زمینه برای پاکسازی خودکار فایل‌های خروجی قدیمی‌تر از ۲ ساعت
189
  async def cleanup_old_files_loop():
190
  print("--- File Cleanup Worker Started ---")
191
  while True:
@@ -194,11 +192,9 @@ async def cleanup_old_files_loop():
194
  now = time.time()
195
  for filename in os.listdir(TEMP_DIR):
196
  file_path = os.path.join(TEMP_DIR, filename)
197
- # شناسایی فایل‌های خروجی نهایی
198
  if filename.endswith(".mp4") and "_final_" in filename:
199
  try:
200
  mtime = os.path.getmtime(file_path)
201
- # ۷۲۰۰ ثانیه معادل ۲ ساعت است
202
  if now - mtime > 7200:
203
  os.remove(file_path)
204
  print(f"Cleanup: Removed expired final video: {filename}")
@@ -451,8 +447,6 @@ def generate_subtitle_video(data: ProcessRequest, temp_dir: str):
451
  seg.words.sort(key=lambda x: x.start)
452
  words = [w.word for w in seg.words]
453
 
454
- # استفاده از منطق فریم‌بندی زمانی دقیق برای تمامی استایل‌ها
455
- # این کار مانع از کشیده شدن یا جابجایی زمان کلمات دیگر در صورت حذف یک کلمه می‌شود
456
  SUB_FRAME_DURATION = 0.025 if data.style.name == "falling_words" else 0.05
457
  time_cursor = start_time
458
  ANIMATION_DURATION = 0.4
@@ -530,82 +524,31 @@ def generate_style_api(req: StylePrompt):
530
  except: continue
531
  return {"primaryColor":"#FFFFFF", "outlineColor":"#000000", "font":"vazir", "fontSize":60, "backType":"solid"}
532
 
533
- def smart_split_audio(file_path):
534
- """
535
- برش هوشمند فایل صوتی بر اساس درخواست:
536
- - اگر زیر ۳۰ ثانیه باشد برشی نمی‌خورد.
537
- - در غیر اینصورت بین ۱۵ تا ۳۰ ثانیه دنبال سکوت می‌گردد.
538
- - اگر در این بازه سکوت مناسبی نبود، بین ۱۰ تا ۴۰ ثانیه دنبال سکوت می‌گردد.
539
- """
540
- audio = AudioSegment.from_file(file_path)
541
- length_ms = len(audio)
542
- chunks_info = []
543
 
544
- start_ms = 0
545
- # محاسبه حجم صدای میانگین کل فایل به عنوان معیار سکوت (۴۰ درصد صدای کل)
546
- avg_rms = audio.rms
547
- silence_threshold = avg_rms * 0.4 if avg_rms > 0 else 500
548
 
549
- while start_ms < length_ms:
550
- # اگر زمان باقیمانده کمتر از ۳۰ ثانیه بود، همه را در یک قطعه پردازش کن
551
- if length_ms - start_ms <= 30 * 1000:
552
- end_ms = length_ms
553
- else:
554
- # 1. جستجوی سکوت در بازه 15 تا 30 ثانیه
555
- search_start = start_ms + (15 * 1000)
556
- search_end = min(start_ms + (30 * 1000), length_ms)
557
- window = audio[search_start:search_end]
558
-
559
- min_loudness = float('inf')
560
- best_cut_rel = search_end - search_start
561
- step = 100
562
 
563
- if len(window) > 0:
564
- for i in range(0, len(window), step):
565
- chunk_check = window[i:i+step]
566
- if chunk_check.rms < min_loudness:
567
- min_loudness = chunk_check.rms
568
- best_cut_rel = i
569
-
570
- # 2. بررسی اینکه آیا سکوت پیدا شده خوب است؟
571
- # و اینکه آیا اصلاً اجازه گسترش بازه تا 40 ثانیه را داریم؟ (باقیمانده > 40s)
572
- if min_loudness > silence_threshold and (length_ms - start_ms) > 30 * 1000:
573
- # جستجوی ثانویه در بازه 10 تا 40 ثانیه (چون سکوت خوبی پیدا نشد)
574
- search_start_fb = start_ms + (10 * 1000)
575
- search_end_fb = min(start_ms + (40 * 1000), length_ms)
576
- window_fb = audio[search_start_fb:search_end_fb]
577
-
578
- min_loudness_fb = float('inf')
579
- best_cut_rel_fb = search_end_fb - search_start_fb
580
-
581
- if len(window_fb) > 0:
582
- for i in range(0, len(window_fb), step):
583
- chunk_check = window_fb[i:i+step]
584
- if chunk_check.rms < min_loudness_fb:
585
- min_loudness_fb = chunk_check.rms
586
- best_cut_rel_fb = i
587
-
588
- # اعمال نتیجه جستجوی ثانویه (گسترده‌تر)
589
- end_ms = search_start_fb + best_cut_rel_fb
590
- else:
591
- # اعمال نتیجه جستجوی اولیه (همان 15 تا 30 ثانیه)
592
- end_ms = search_start + best_cut_rel
593
 
594
- chunk = audio[start_ms:end_ms]
595
- chunk_filename = f"{file_path}_{start_ms}.wav"
596
- chunk.export(chunk_filename, format="wav", parameters=["-ar", "16000"])
597
-
598
- chunks_info.append({
599
- "path": chunk_filename,
600
- "offset": start_ms / 1000.0,
601
- "duration": (end_ms - start_ms) / 1000.0,
602
- "index": len(chunks_info)
603
- })
604
- start_ms = end_ms
605
-
606
- return chunks_info
607
-
608
- # --- پردازش موقت در صف پس‌زمینه برای داک داکر غیرهمگام ---
609
  async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_path: str):
610
  job = upload_jobs.get(task_id)
611
  if not job:
@@ -613,6 +556,7 @@ async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_
613
 
614
  job.status = UploadJobStatus.PROCESSING
615
  try:
 
616
  proc1 = await asyncio.create_subprocess_exec(
617
  "ffmpeg", "-y", "-i", raw_path, "-r", "30", "-c:v", "libx264",
618
  "-preset", "ultrafast", "-c:a", "copy", fixed_path,
@@ -622,32 +566,72 @@ async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_
622
 
623
  w, h, total_duration = get_video_info(fixed_path)
624
 
 
625
  proc2 = await asyncio.create_subprocess_exec(
626
- "ffmpeg", "-y", "-i", fixed_path, "-vn", "-acodec", "pcm_s16le",
627
- "-ar", "16000", "-ac", "1", audio_path,
628
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
629
  )
630
  await proc2.communicate()
631
 
632
- # استفاده از سیستم جدید هوشمند برش بین 15 تا 30 ثانیه در نقاط سکوت (گسترش تا 40 ثانیه در صورت لزوم)
633
- audio_chunks = smart_split_audio(audio_path)
634
- print(f"--- [BG Task {task_id}] Processing {len(audio_chunks)} chunks concurrently ---")
635
 
636
- # تمامی قطعات برش خورده به صورت یکجا و موازی ارسال می‌شوند
637
- tasks = [process_single_chunk_async(chunk) for chunk in audio_chunks]
638
- results = await asyncio.gather(*tasks)
639
 
640
- final_segs = []
641
- final_style = None
642
- errors = []
643
-
644
- for segs, style, err in results:
645
- if style and not final_style:
646
- final_style = style
647
- final_segs.extend(segs)
648
- if err:
649
- errors.append(err)
650
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  if os.path.exists(audio_path):
652
  try: os.remove(audio_path)
653
  except: pass
@@ -655,19 +639,52 @@ async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_
655
  try: os.remove(raw_path)
656
  except: pass
657
 
658
- if not final_segs:
659
- err_summary = " | ".join(list(set(errors))) if errors else "هیچ متنی از تحلیل صدا استخراج نشد."
660
  job.status = UploadJobStatus.FAILED
661
- job.error_message = f"تبدیل گفتار به متن ناموفق بود. خطاهای شناسایی شده: {err_summary}"
662
  return
663
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  job.result = {
665
  "file_id": task_id,
666
  "url": f"/temp/{task_id}.mp4",
667
  "width": w,
668
  "height": h,
669
  "segments": final_segs,
670
- "suggested_style": final_style or {"primaryColor": "#FFFFFF", "outlineColor": "#000000", "font": "vazir", "fontSize": 60, "backType": "solid"}
671
  }
672
  job.status = UploadJobStatus.COMPLETED
673
 
@@ -681,171 +698,13 @@ async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_
681
  job.status = UploadJobStatus.FAILED
682
  job.error_message = f"Processing Error: {str(e)}"
683
 
684
- # --- پردازشگر ارتباط با API وب‌سرویس جدید در هاگینگ‌فیس شما ---
685
- async def process_single_chunk_async(chunk_data):
686
- c_path = chunk_data["path"]
687
- c_offset = chunk_data["offset"]
688
- c_duration = chunk_data["duration"]
689
- chunk_idx = chunk_data["index"]
690
-
691
- print(f"[{chunk_idx}] Start processing chunk... Offset: {c_offset}s, Duration: {c_duration}s")
692
- result_segs = []
693
- style_suggestion = None
694
- error_msg = None
695
-
696
- async with GEMINI_SEMAPHORE:
697
- for attempt in range(5):
698
- try:
699
- chunk_run_id = f"stt_chunk_{uuid.uuid4().hex[:12]}"
700
-
701
- upload_url = f"{NEW_SPACE_URL}/api/webhook/upload"
702
- print(f"[{chunk_idx}] Uploading chunk file to {upload_url} (Attempt {attempt + 1}/5)...")
703
-
704
- with open(c_path, "rb") as f:
705
- files = {"file": f}
706
- data = {"run_id": chunk_run_id, "ext": "wav"}
707
- upload_res = await asyncio.to_thread(
708
- lambda: requests.post(upload_url, data=data, files=files, timeout=30)
709
- )
710
-
711
- if not upload_res.ok:
712
- error_msg = f"خطا در بارگذاری چنک صوتی روی سرور: {upload_res.status_code}"
713
- print(f"[{chunk_idx}] Warning: {error_msg}")
714
- await asyncio.sleep(2)
715
- continue
716
-
717
- generate_url = f"{NEW_SPACE_URL}/api/generate"
718
- file_url = f"{NEW_SPACE_URL}/static/images/{chunk_run_id}.wav"
719
-
720
- config_prompt = f"config_url: {file_url} config_lang: Persian / فارسی config_optimize: true config_token: none"
721
-
722
- print(f"[{chunk_idx}] Dispatching transcription action to {generate_url}...")
723
- payload = {
724
- "prompt": config_prompt,
725
- "width": 1024,
726
- "height": 1024,
727
- "action_name": "matnbesada"
728
- }
729
-
730
- gen_res = await asyncio.to_thread(
731
- lambda: requests.post(generate_url, json=payload, timeout=30)
732
- )
733
-
734
- if not gen_res.ok:
735
- error_msg = f"خطا در ارسال درخواست رندر به سرور: {gen_res.status_code}"
736
- print(f"[{chunk_idx}] Warning: {error_msg}")
737
- await asyncio.sleep(2)
738
- continue
739
-
740
- gen_data = gen_res.json()
741
- if gen_data.get("status") != "success" or not gen_data.get("run_id"):
742
- error_msg = "پاسخ نامعتبر در ثبت تسک پردازش."
743
- print(f"[{chunk_idx}] Warning: {error_msg}")
744
- await asyncio.sleep(2)
745
- continue
746
-
747
- action_run_id = gen_data["run_id"]
748
- print(f"[{chunk_idx}] Action dispatched successfully. Action ID: {action_run_id}")
749
-
750
- json_url = f"{NEW_SPACE_URL}/static/images/{action_run_id}.json"
751
-
752
- print(f"[{chunk_idx}] Starting status polling for json file...")
753
- is_ready = False
754
- poll_attempts = 0
755
- max_poll_attempts = 100
756
-
757
- while poll_attempts < max_poll_attempts:
758
- poll_attempts += 1
759
- check_res = await asyncio.to_thread(
760
- lambda: requests.head(json_url, timeout=10)
761
- )
762
- if check_res.ok:
763
- is_ready = True
764
- break
765
- await asyncio.sleep(3)
766
-
767
- if not is_ready:
768
- error_msg = "زمان انتظار برای دریافت خروجی به پایان رسید."
769
- print(f"[{chunk_idx}] Warning: {error_msg}")
770
- continue
771
-
772
- print(f"[{chunk_idx}] Files are ready! Fetching results...")
773
- fetch_json_res = await asyncio.to_thread(lambda: requests.get(json_url, timeout=15))
774
-
775
- raw_word_items = []
776
- if fetch_json_res.ok:
777
- try:
778
- words_data = json.loads(fetch_json_res.text)
779
- if isinstance(words_data, dict):
780
- if "words" in words_data:
781
- words_data = words_data["words"]
782
- elif "segments" in words_data:
783
- words_data = words_data["segments"]
784
- else:
785
- for val in words_data.values():
786
- if isinstance(val, list):
787
- words_data = val
788
- break
789
- if isinstance(words_data, list):
790
- raw_word_items = words_data
791
- except Exception as je:
792
- print(f"[{chunk_idx}] JSON decode failed: {je}")
793
-
794
- if raw_word_items:
795
- print(f"[{chunk_idx}] Transcribed successfully. Processing {len(raw_word_items)} raw words...")
796
-
797
- chunk_size = 7
798
- for k in range(0, len(raw_word_items), chunk_size):
799
- sub = raw_word_items[k:k+chunk_size]
800
- if not sub: continue
801
- sc_words = []
802
- for w in sub:
803
- if not isinstance(w, dict): continue
804
-
805
- word_text = w.get("word", w.get("text", ""))
806
- word_start = float(w.get("start", w.get("start_time", 0)))
807
- word_end = float(w.get("end", w.get("end_time", 0)))
808
-
809
- sc_words.append({
810
- "word": word_text,
811
- "start": round(c_offset + word_start, 3),
812
- "end": round(c_offset + word_end, 3),
813
- "highlight": False
814
- })
815
-
816
- if sc_words:
817
- result_segs.append({
818
- "start": sc_words[0]["start"],
819
- "end": sc_words[-1]["end"],
820
- "text": " ".join([x["word"] for x in sc_words]),
821
- "words": sc_words
822
- })
823
-
824
- print(f"[{chunk_idx}] Successfully generated {len(result_segs)} subtitle segments.")
825
- error_msg = None
826
- break
827
- else:
828
- error_msg = "هیچ زمان‌بندی خامی از سرور دریافت نشد."
829
- print(f"[{chunk_idx}] Warning: {error_msg}")
830
-
831
- except Exception as e:
832
- error_msg = f"{type(e).__name__}: {str(e)}"
833
- print(f"[{chunk_idx}] ERROR calling custom Space STT: {error_msg}")
834
- await asyncio.sleep(2 + attempt * 2)
835
-
836
- if os.path.exists(c_path):
837
- try: os.remove(c_path)
838
- except: pass
839
-
840
- return result_segs, style_suggestion, error_msg
841
-
842
  @app.post("/api/upload")
843
  async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
844
  task_id = str(uuid.uuid4())[:8]
845
  ext = file.filename.split('.')[-1]
846
  raw_path = f"{TEMP_DIR}/{task_id}_raw.{ext}"
847
  fixed_path = f"{TEMP_DIR}/{task_id}.mp4"
848
- audio_path = f"{TEMP_DIR}/{task_id}.wav"
849
 
850
  try:
851
  with open(raw_path, "wb") as f:
@@ -884,13 +743,11 @@ async def delete_project_files(file_id: str):
884
  if "/" in file_id or "\\" in file_id or ".." in file_id:
885
  raise HTTPException(400, "Invalid file_id")
886
 
887
- # ۱. حذف ویدیوی اصلی آپلود شده
888
  raw_video_path = os.path.join(TEMP_DIR, f"{file_id}.mp4")
889
  if os.path.exists(raw_video_path):
890
  try: os.remove(raw_video_path)
891
  except Exception as e: print(f"Error removing raw video: {e}")
892
 
893
- # ۲. حذف تمامی خروجی‌های رندر شده مرتبط با این پروژه
894
  final_patterns = os.path.join(TEMP_DIR, f"{file_id}_final_*.mp4")
895
  for f_path in glob.glob(final_patterns):
896
  try: os.remove(f_path)
 
20
  from pydantic import BaseModel
21
  from PIL import Image, ImageDraw, ImageFont
22
  from pydub import AudioSegment
23
+ from groq import Groq # اضافه شدن کتابخانه Groq
24
 
25
  app = FastAPI()
26
 
 
61
  "hasti": "Hasti.ttf",
62
  }
63
 
64
+ # دریافت توکن Groq و Gemini از تنظیمات سکرت اسپیس
65
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
66
+ API_KEYS = [os.getenv("GEMINI_API_KEY")] if os.getenv("GEMINI_API_KEY") else []
 
67
 
68
  # --- سیستم صف و پایش موقت آپلود غیرهمگام ---
69
  class UploadJobStatus:
 
110
 
111
  load_all_styles()
112
 
113
+ # تنظیم همزمانی پردازش
114
  CONCURRENCY_LIMIT = 20
115
  GEMINI_SEMAPHORE = asyncio.Semaphore(CONCURRENCY_LIMIT)
116
 
 
184
  job.error_message = str(e)
185
  render_queue.task_done()
186
 
 
187
  async def cleanup_old_files_loop():
188
  print("--- File Cleanup Worker Started ---")
189
  while True:
 
192
  now = time.time()
193
  for filename in os.listdir(TEMP_DIR):
194
  file_path = os.path.join(TEMP_DIR, filename)
 
195
  if filename.endswith(".mp4") and "_final_" in filename:
196
  try:
197
  mtime = os.path.getmtime(file_path)
 
198
  if now - mtime > 7200:
199
  os.remove(file_path)
200
  print(f"Cleanup: Removed expired final video: {filename}")
 
447
  seg.words.sort(key=lambda x: x.start)
448
  words = [w.word for w in seg.words]
449
 
 
 
450
  SUB_FRAME_DURATION = 0.025 if data.style.name == "falling_words" else 0.05
451
  time_cursor = start_time
452
  ANIMATION_DURATION = 0.4
 
524
  except: continue
525
  return {"primaryColor":"#FFFFFF", "outlineColor":"#000000", "font":"vazir", "fontSize":60, "backType":"solid"}
526
 
527
+ # --- تابع جدید برای ارسال فایل صوتی به Groq و تحلیل دقیق کلمه‌به‌کلمه ---
528
+ async def transcribe_audio_via_groq(audio_path: str):
529
+ if not GROQ_API_KEY:
530
+ raise Exception("متغیر محیطی GROQ_API_KEY در قسمت Secrets تنظیم نشده است.")
 
 
 
 
 
 
531
 
532
+ # راه‌اندازی کلاینت Groq
533
+ client = Groq(api_key=GROQ_API_KEY)
 
 
534
 
535
+ # فراخوانی متد همگام به صورت غ��رهمگام با استفاده از نخ مستقل
536
+ def call_groq():
537
+ with open(audio_path, "rb") as file:
538
+ return client.audio.transcriptions.create(
539
+ file=(os.path.basename(audio_path), file.read()),
540
+ model="whisper-large-v3",
541
+ response_format="verbose_json",
542
+ timestamp_granularities=["word"],
543
+ temperature=0.0,
544
+ language="fa", # تضمین پردازش به عنوان زبان فارسی
545
+ prompt="دقیق‌ترین زیرنویس چسبیده فارسی با رعایت علائم نگارشی، فاصله‌گذاری، نیم‌فاصله‌ها و بدون جا انداختن کلمات."
546
+ )
 
547
 
548
+ transcription = await asyncio.to_thread(call_groq)
549
+ return transcription
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
 
551
+ # --- پردازش موقت در صف پس‌زمینه ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  async def bg_process_upload(task_id: str, raw_path: str, fixed_path: str, audio_path: str):
553
  job = upload_jobs.get(task_id)
554
  if not job:
 
556
 
557
  job.status = UploadJobStatus.PROCESSING
558
  try:
559
+ # ۱. تبدیل ویدیوی خام به فرمت استاندارد 30fps h264
560
  proc1 = await asyncio.create_subprocess_exec(
561
  "ffmpeg", "-y", "-i", raw_path, "-r", "30", "-c:v", "libx264",
562
  "-preset", "ultrafast", "-c:a", "copy", fixed_path,
 
566
 
567
  w, h, total_duration = get_video_info(fixed_path)
568
 
569
+ # ۲. استخراج مستقیم صدا به صورت MP3 با نرخ بیت پایین‌تر (کیفیت عالی برای Whisper و حجم بسیار کم زیر ۲۵ مگابایت)
570
  proc2 = await asyncio.create_subprocess_exec(
571
+ "ffmpeg", "-y", "-i", fixed_path, "-vn", "-acodec", "libmp3lame",
572
+ "-ar", "16000", "-ac", "1", "-b:a", "64k", audio_path,
573
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
574
  )
575
  await proc2.communicate()
576
 
577
+ print(f"--- [BG Task {task_id}] Transcribing audio with Groq Whisper-large-v3 in one step ---")
 
 
578
 
579
+ # ۳. ارسال مستقیم فایل صوتی فشرده شده به Groq
580
+ transcription = await transcribe_audio_via_groq(audio_path)
 
581
 
582
+ # ۴. تحلیل خروجی Pydantic یا دیکشنری دریافتی
583
+ if hasattr(transcription, "model_dump"):
584
+ data = transcription.model_dump()
585
+ elif hasattr(transcription, "dict"):
586
+ data = transcription.dict()
587
+ elif isinstance(transcription, dict):
588
+ data = transcription
589
+ else:
590
+ try:
591
+ data = dict(transcription)
592
+ except:
593
+ data = {}
594
+ if hasattr(transcription, "words"):
595
+ data["words"] = getattr(transcription, "words", [])
596
+ if hasattr(transcription, "segments"):
597
+ data["segments"] = getattr(transcription, "segments", [])
598
+
599
+ raw_word_items = data.get("words", [])
600
+ # در صورت نبود آرایه مستقیم کلمات، آن را از میان سگمنت‌ها استخراج می‌کنیم
601
+ if not raw_word_items and "segments" in data:
602
+ segments = data.get("segments", [])
603
+ for seg in segments:
604
+ if isinstance(seg, dict) and "words" in seg:
605
+ raw_word_items.extend(seg["words"])
606
+ elif hasattr(seg, "words"):
607
+ raw_word_items.extend(getattr(seg, "words", []))
608
+
609
+ # حالت بک‌آپ (اگر کلمه‌به‌کلمه خالی بود، از سطح سگمنت کلمات را استخراج می‌کنیم)
610
+ if not raw_word_items:
611
+ segments = data.get("segments", [])
612
+ if segments:
613
+ print(f"[{task_id}] No word timestamps found. Falling back to segment level timestamps.")
614
+ for seg in segments:
615
+ seg_text = getattr(seg, "text", "") if not isinstance(seg, dict) else seg.get("text", "")
616
+ seg_start = float(getattr(seg, "start", 0)) if not isinstance(seg, dict) else float(seg.get("start", 0))
617
+ seg_end = float(getattr(seg, "end", 0)) if not isinstance(seg, dict) else float(seg.get("end", 0))
618
+ words_list = seg_text.split()
619
+ duration = seg_end - seg_start
620
+ word_duration = duration / max(1, len(words_list))
621
+
622
+ sc_words = []
623
+ for i, w in enumerate(words_list):
624
+ w_start = seg_start + (i * word_duration)
625
+ w_end = w_start + word_duration
626
+ sc_words.append({
627
+ "word": w,
628
+ "start": round(w_start, 3),
629
+ "end": round(w_end, 3),
630
+ "highlight": False
631
+ })
632
+ raw_word_items.extend(sc_words)
633
+
634
+ # پاکسازی سریع فایلهای موقت صوتی و ویدئوی خام
635
  if os.path.exists(audio_path):
636
  try: os.remove(audio_path)
637
  except: pass
 
639
  try: os.remove(raw_path)
640
  except: pass
641
 
642
+ if not raw_word_items:
 
643
  job.status = UploadJobStatus.FAILED
644
+ job.error_message = "تبدیل گفتار به متن ناموفق بود. هیچ متنی شناسایی نشد."
645
  return
646
 
647
+ # ۵. گروه‌بندی کلمات دریافت شده به صورت دسته‌های ۵ تایی (بسیار خوانا و استاندارد برای ریلز)
648
+ final_segs = []
649
+ chunk_size = 5
650
+ for k in range(0, len(raw_word_items), chunk_size):
651
+ sub = raw_word_items[k:k+chunk_size]
652
+ if not sub: continue
653
+ sc_words = []
654
+ for w in sub:
655
+ if not isinstance(w, dict):
656
+ word_text = getattr(w, "word", getattr(w, "text", ""))
657
+ word_start = float(getattr(w, "start", getattr(w, "start_time", 0)))
658
+ word_end = float(getattr(w, "end", getattr(w, "end_time", 0)))
659
+ else:
660
+ word_text = w.get("word", w.get("text", ""))
661
+ word_start = float(w.get("start", w.get("start_time", 0)))
662
+ word_end = float(w.get("end", w.get("end_time", 0)))
663
+
664
+ sc_words.append({
665
+ "word": word_text,
666
+ "start": round(word_start, 3),
667
+ "end": round(word_end, 3),
668
+ "highlight": False
669
+ })
670
+
671
+ if sc_words:
672
+ final_segs.append({
673
+ "start": sc_words[0]["start"],
674
+ "end": sc_words[-1]["end"],
675
+ "text": " ".join([x["word"] for x in sc_words]),
676
+ "words": sc_words
677
+ })
678
+
679
+ suggested_style = {"primaryColor": "#FFFFFF", "outlineColor": "#000000", "font": "vazir", "fontSize": 60, "backType": "solid"}
680
+
681
  job.result = {
682
  "file_id": task_id,
683
  "url": f"/temp/{task_id}.mp4",
684
  "width": w,
685
  "height": h,
686
  "segments": final_segs,
687
+ "suggested_style": suggested_style
688
  }
689
  job.status = UploadJobStatus.COMPLETED
690
 
 
698
  job.status = UploadJobStatus.FAILED
699
  job.error_message = f"Processing Error: {str(e)}"
700
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  @app.post("/api/upload")
702
  async def upload(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
703
  task_id = str(uuid.uuid4())[:8]
704
  ext = file.filename.split('.')[-1]
705
  raw_path = f"{TEMP_DIR}/{task_id}_raw.{ext}"
706
  fixed_path = f"{TEMP_DIR}/{task_id}.mp4"
707
+ audio_path = f"{TEMP_DIR}/{task_id}.mp3" # ذخیره به عنوان MP3
708
 
709
  try:
710
  with open(raw_path, "wb") as f:
 
743
  if "/" in file_id or "\\" in file_id or ".." in file_id:
744
  raise HTTPException(400, "Invalid file_id")
745
 
 
746
  raw_video_path = os.path.join(TEMP_DIR, f"{file_id}.mp4")
747
  if os.path.exists(raw_video_path):
748
  try: os.remove(raw_video_path)
749
  except Exception as e: print(f"Error removing raw video: {e}")
750
 
 
751
  final_patterns = os.path.join(TEMP_DIR, f"{file_id}_final_*.mp4")
752
  for f_path in glob.glob(final_patterns):
753
  try: os.remove(f_path)