bep40 commited on
Commit
bcaa2dc
·
verified ·
1 Parent(s): 07a34c4

Restore Space to revision 33c3dda

Browse files
Files changed (1) hide show
  1. ai_patch.py +32 -146
ai_patch.py CHANGED
@@ -518,119 +518,8 @@ def _make_short_frame_full(post, img_path, out_path):
518
  bg.save(out_path, quality=92)
519
 
520
 
521
-
522
-
523
- def _summary_segments_from_post(post, max_segments=7):
524
- """Extract clean summary segments. No old context; each bullet/paragraph becomes a scene."""
525
- raw = _clean(post.get('text') or post.get('title') or '')
526
- raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
527
- raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
528
- # Prefer bullet points if available
529
- lines = []
530
- for ln in raw.splitlines():
531
- ln = _clean(re.sub(r'^[•\-\*\d\.\)\s]+', '', ln))
532
- if not ln: continue
533
- if len(ln) >= 18 and not ln.lower().startswith(('điểm chính', 'tiêu đề', 'sapo')):
534
- lines.append(ln)
535
- if len(lines) < 2:
536
- # split into sentences/clauses
537
- lines = []
538
- for s in re.split(r'(?<=[\.\!\?])\s+', raw):
539
- s = _clean(s)
540
- if len(s) >= 25:
541
- lines.append(s)
542
- segs = _dedupe_units(lines, max_units=max_segments)
543
- if not segs:
544
- segs = [post.get('title','Bản tin VNEWS')]
545
- return segs[:max_segments]
546
-
547
-
548
- def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
549
- """Create one 9:16 frame for a single summary segment."""
550
- if Image is None:
551
- return _make_short_frame_full(post, img_path, out_path)
552
- W, H = 1080, 1920
553
- bg = Image.new('RGB', (W, H), (10, 10, 10))
554
- try:
555
- im = Image.open(img_path).convert('RGB')
556
- # background blur/cover approximation: cover whole canvas
557
- ratio = im.width / max(1, im.height)
558
- target_ratio = W / H
559
- if ratio > target_ratio:
560
- nh = H; nw = int(nh * ratio)
561
- else:
562
- nw = W; nh = int(nw / ratio)
563
- im2 = im.resize((nw, nh))
564
- left=(nw-W)//2; top=(nh-H)//2
565
- im2 = im2.crop((left, top, left+W, top+H))
566
- bg.paste(im2, (0,0))
567
- # dark overlay
568
- overlay = Image.new('RGB',(W,H),(0,0,0))
569
- bg = Image.blend(bg, overlay, 0.48)
570
- # hero image block top
571
- hero_h = 720
572
- ratio = im.width / max(1, im.height)
573
- target_ratio = W / hero_h
574
- if ratio > target_ratio:
575
- nh = hero_h; nw = int(nh * ratio)
576
- else:
577
- nw = W; nh = int(nw / ratio)
578
- hero = im.resize((nw, nh))
579
- left=(nw-W)//2; top=(nh-hero_h)//2
580
- hero = hero.crop((left, top, left+W, top+hero_h))
581
- bg.paste(hero,(0,0))
582
- except Exception:
583
- pass
584
- draw = ImageDraw.Draw(bg)
585
- try:
586
- font_brand = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 34)
587
- font_label = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 32)
588
- font_seg = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 58)
589
- font_title = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 34)
590
- font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28)
591
- except Exception:
592
- font_brand = font_label = font_seg = font_title = font_small = None
593
- # gradient/dark content panel
594
- draw.rectangle((0,680,W,H), fill=(12,12,12))
595
- # progress dots
596
- dot_y=742; dot_x=48
597
- for i in range(total):
598
- fill=(92,184,122) if i==idx else (70,70,70)
599
- draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
600
- draw.text((48, 780), 'VNEWS AI SHORT', fill=(110, 231, 143), font=font_brand)
601
- topic = emotion or 'Tin tức'
602
- draw.rounded_rectangle((48, 834, 260, 880), radius=20, fill=(28,70,45))
603
- draw.text((66, 842), f'Đoạn {idx+1}/{total}', fill=(235,235,235), font=font_small)
604
- y = 940
605
- maxw = W - 96
606
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
607
- draw.text((48, y), ln, fill=(255,255,255), font=font_seg)
608
- y += 74
609
- if y > 1500: break
610
- # Title at bottom as small context only, not full article
611
- title = post.get('title','')
612
- y2 = 1640
613
- draw.line((48,y2-22,W-48,y2-22), fill=(70,70,70), width=2)
614
- for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
615
- draw.text((48, y2), ln, fill=(220,220,220), font=font_title)
616
- y2 += 46
617
- bg.save(out_path, quality=92)
618
-
619
-
620
- def _estimate_audio_duration(path, fallback=4.0):
621
- try:
622
- pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
623
- return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
624
- except Exception:
625
- return fallback
626
-
627
-
628
  @app.post('/api/ai/short/{post_id}')
629
  async def patched_ai_short(post_id: str, request: Request):
630
- """Create 9:16 AI short as multiple timed scenes.
631
- Each summary paragraph/bullet becomes its own visual scene.
632
- No subtitle burn-in; only scene text is rendered on frames.
633
- """
634
  try:
635
  body = await request.json()
636
  except Exception:
@@ -645,28 +534,31 @@ async def patched_ai_short(post_id: str, request: Request):
645
  if not post:
646
  return JSONResponse({'error': 'post not found'}, status_code=404)
647
 
648
- segments = _summary_segments_from_post(post, max_segments=7)
649
- seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
650
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
651
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
652
  out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
653
  if os.path.exists(out_mp4):
654
  post['video'] = '/api/ai/short-file/' + post_id + suffix
655
  post['short_voice'] = voice
656
  post['short_emotion'] = emotion
657
  post['short_speed'] = speed
658
- post['short_segments'] = segments
659
- post['short_subtitles'] = False
660
  base._save_ai_wall(posts)
661
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
662
  if base.gTTS is None:
663
  return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
664
 
665
  work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
666
  os.makedirs(work, exist_ok=True)
667
  img = os.path.join(work, 'image.jpg')
 
 
 
 
668
  try:
669
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
 
 
 
670
  edge_voice = {
671
  'nam': 'vi-VN-NamMinhNeural',
672
  'male': 'vi-VN-NamMinhNeural',
@@ -674,42 +566,36 @@ async def patched_ai_short(post_id: str, request: Request):
674
  'female': 'vi-VN-HoaiMyNeural',
675
  'mien-nam': 'vi-VN-HoaiMyNeural',
676
  }.get(voice, 'vi-VN-HoaiMyNeural')
677
- part_files=[]
678
- for idx, seg in enumerate(segments):
679
- frame = os.path.join(work, f'frame_{idx:02d}.jpg')
680
- aud = os.path.join(work, f'voice_{idx:02d}.mp3')
681
- aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
682
- part = os.path.join(work, f'part_{idx:02d}.mp4')
683
- _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
684
- spoken = _emotion_script(seg, emotion)
685
  try:
686
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
687
- except Exception:
688
- tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
689
- try:
690
- base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
691
- except TypeError:
692
- base.gTTS(spoken, lang='vi', slow=False).save(aud)
693
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
694
- dur = _estimate_audio_duration(aud_fast, fallback=4.0) + 0.35
695
- # No subtitles. Text is already part of the scene frame.
696
- subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
697
- part_files.append(part)
698
- concat = os.path.join(work,'concat.txt')
699
- with open(concat,'w',encoding='utf-8') as f:
700
- for p in part_files:
701
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
702
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
703
  post['video'] = '/api/ai/short-file/' + post_id + suffix
704
  post['short_voice'] = voice
705
  post['short_emotion'] = emotion
706
  post['short_speed'] = speed
707
- post['short_segments'] = segments
708
- post['short_subtitles'] = False
709
  base._save_ai_wall(posts)
710
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
711
  except Exception as e:
712
- return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
713
 
714
 
715
  @app.get('/api/ai/short-file/{file_id}')
 
518
  bg.save(out_path, quality=92)
519
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  @app.post('/api/ai/short/{post_id}')
522
  async def patched_ai_short(post_id: str, request: Request):
 
 
 
 
523
  try:
524
  body = await request.json()
525
  except Exception:
 
534
  if not post:
535
  return JSONResponse({'error': 'post not found'}, status_code=404)
536
 
 
 
537
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
538
+ suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
539
  out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
540
  if os.path.exists(out_mp4):
541
  post['video'] = '/api/ai/short-file/' + post_id + suffix
542
  post['short_voice'] = voice
543
  post['short_emotion'] = emotion
544
  post['short_speed'] = speed
 
 
545
  base._save_ai_wall(posts)
546
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed})
547
  if base.gTTS is None:
548
  return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
549
 
550
  work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
551
  os.makedirs(work, exist_ok=True)
552
  img = os.path.join(work, 'image.jpg')
553
+ frame = os.path.join(work, 'frame.jpg')
554
+ audio = os.path.join(work, 'voice.mp3')
555
+ audio_fast = os.path.join(work, 'voice_fast.mp3')
556
+ srt = os.path.join(work, 'subtitles.srt')
557
  try:
558
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
559
+ _make_short_frame_full(post, img, frame)
560
+ script = _tts_script_smart(post, emotion)
561
+ # True selectable voice via edge-tts if installed. Fallback to gTTS.
562
  edge_voice = {
563
  'nam': 'vi-VN-NamMinhNeural',
564
  'male': 'vi-VN-NamMinhNeural',
 
566
  'female': 'vi-VN-HoaiMyNeural',
567
  'mien-nam': 'vi-VN-HoaiMyNeural',
568
  }.get(voice, 'vi-VN-HoaiMyNeural')
569
+ try:
570
+ subprocess.run(['python', '-m', 'edge_tts', '--voice', edge_voice, '--text', script, '--write-media', audio], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=160)
571
+ except Exception:
572
+ tld = 'com.vn' if voice in ('nu', 'female', 'mien-nam') else 'com'
 
 
 
 
573
  try:
574
+ base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
575
+ except TypeError:
576
+ base.gTTS(script, lang='vi', slow=False).save(audio)
577
+ subprocess.run(['ffmpeg', '-y', '-i', audio, '-filter:a', f'atempo={speed}', '-vn', audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
578
+ # Estimate duration for subtitle timing.
579
+ duration = 30.0
580
+ try:
581
+ pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
582
+ duration = float((pr.stdout or b'30').decode().strip() or 30)
583
+ except Exception:
584
+ pass
585
+ _write_srt(script, srt, duration)
586
+ # Burn subtitles at bottom, with readable style.
587
+ vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=34,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=3,Shadow=1,Alignment=2,MarginV=90'".format(srt.replace("'", "\\'"))
588
+ cmd = ['ffmpeg', '-y', '-loop', '1', '-i', frame, '-i', audio_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-vf', vf, out_mp4]
589
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
 
590
  post['video'] = '/api/ai/short-file/' + post_id + suffix
591
  post['short_voice'] = voice
592
  post['short_emotion'] = emotion
593
  post['short_speed'] = speed
594
+ post['short_subtitles'] = True
 
595
  base._save_ai_wall(posts)
596
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True})
597
  except Exception as e:
598
+ return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:180]}, status_code=500)
599
 
600
 
601
  @app.get('/api/ai/short-file/{file_id}')