diff --git a/blogwriter/cli.py b/blogwriter/cli.py index 1ed347f..b32a5f9 100644 --- a/blogwriter/cli.py +++ b/blogwriter/cli.py @@ -373,6 +373,11 @@ def init(): else: services['claude_web'] = False + if click.confirm(" Google Gemini Pro(Web) 사용 중이신가요?", default=False): + services['gemini_web'] = True + else: + services['gemini_web'] = False + profile['services'] = services # Step 5: API Keys diff --git a/bots/converters/smart_video_router.py b/bots/converters/smart_video_router.py index fdcd877..ba765bb 100644 --- a/bots/converters/smart_video_router.py +++ b/bots/converters/smart_video_router.py @@ -195,9 +195,9 @@ class SmartVideoRouter: elif engine == 'ffmpeg_slides': result = self._generate_ffmpeg(prompt_text, output_path) else: - # veo3, seedance2, runway, etc. — stub: not yet implemented - logger.warning(f"{engine} 구현 미완성 — 폴백 트리거") - result = '' + # veo3, seedance2, runway — V3.1 구현 예정, ffmpeg_slides로 자동 폴백 + logger.warning(f"{engine} 구현 미완성 — ffmpeg_slides로 자동 폴백") + result = self._generate_ffmpeg(prompt_text, output_path) if result: # Update cost tracking diff --git a/bots/converters/video_engine.py b/bots/converters/video_engine.py index 865b25c..c7cf1e5 100644 --- a/bots/converters/video_engine.py +++ b/bots/converters/video_engine.py @@ -6,7 +6,6 @@ 지원 엔진: - FFmpegSlidesEngine: 기존 shorts_converter.py 파이프라인 (슬라이드 + TTS + ffmpeg) - SeedanceEngine: Seedance 2.0 API (AI 영상 생성) - - SoraEngine: OpenAI Sora (미지원 → ffmpeg_slides 폴백) - RunwayEngine: Runway Gen-3 API - VeoEngine: Google Veo 3.1 (미지원 → ffmpeg_slides 폴백) """ @@ -588,22 +587,6 @@ class SeedanceEngine(VideoEngine): return self._fallback(scenes, output_path, **kwargs) -# ─── SoraEngine ──────────────────────────────────────── - -class SoraEngine(VideoEngine): - """ - OpenAI Sora 영상 생성 엔진. - 현재 API 공개 접근 불가 — ffmpeg_slides로 폴백. - """ - - def __init__(self, cfg: dict): - self.cfg = cfg - - def generate(self, scenes: list, output_path: str, **kwargs) -> str: - logger.warning("Sora API 미지원. ffmpeg_slides로 폴백.") - return FFmpegSlidesEngine(self.cfg).generate(scenes, output_path, **kwargs) - - # ─── RunwayEngine ────────────────────────────────────── class RunwayEngine(VideoEngine): @@ -774,7 +757,6 @@ def get_engine(video_cfg: dict) -> VideoEngine: engine_map = { 'ffmpeg_slides': FFmpegSlidesEngine, 'seedance': SeedanceEngine, - 'sora': SoraEngine, 'runway': RunwayEngine, 'veo': VeoEngine, } diff --git a/bots/prompt_layer/korean_preprocessor.py b/bots/prompt_layer/korean_preprocessor.py index d1702ce..b76ee9b 100644 --- a/bots/prompt_layer/korean_preprocessor.py +++ b/bots/prompt_layer/korean_preprocessor.py @@ -204,6 +204,39 @@ PRONUNCIATION_MAP = { 'PPT': '피피티', 'PDF': '피디에프', 'ZIP': '집', + # AI/LLM extended + 'Gemini': '제미나이', + 'Grok': '그록', + 'Copilot': '코파일럿', + 'Perplexity': '퍼플렉시티', + 'Midjourney': '미드저니', + 'Stable Diffusion': '스테이블 디퓨전', + 'DALL-E': '달리', + 'Sora': '소라', + 'Kling': '클링', + 'Runway': '런웨이', + # Dev tools / infra + 'Git': '깃', + 'Linux': '리눅스', + 'Ubuntu': '우분투', + 'Windows': '윈도우', + 'macOS': '맥오에스', + 'Terminal': '터미널', + 'CI/CD': '씨아이씨디', + 'API Gateway': '에이피아이 게이트웨이', + # Finance extended + 'PER': '주가수익비율', + 'PBR': '주가순자산비율', + 'EPS': '주당순이익', + 'ROE': '자기자본이익률', + 'CAGR': '연평균성장률', + # E-commerce / marketing + 'CPC': '클릭당비용', + 'CPM': '천회노출당비용', + 'CTA': '씨티에이', + 'CTR': '클릭률', + 'ROAS': '광고수익률', + 'LTV': '고객생애가치', } # Pause durations in milliseconds by sentence type diff --git a/bots/shorts/caption_renderer.py b/bots/shorts/caption_renderer.py index abc899a..755157e 100644 --- a/bots/shorts/caption_renderer.py +++ b/bots/shorts/caption_renderer.py @@ -375,4 +375,58 @@ def render_captions( ass_content = header + '\n'.join(events) + '\n' ass_path.write_text(ass_content, encoding='utf-8-sig') # BOM for Windows compatibility logger.info(f'ASS 자막 생성: {ass_path.name} ({len(timestamps)}단어, {len(lines)}라인)') + + +# ── Standalone test ────────────────────────────────────────────── + +if __name__ == '__main__': + import sys + import tempfile + from pathlib import Path + + if '--test' not in sys.argv: + print("사용법: python -m bots.shorts.caption_renderer --test") + sys.exit(0) + + print("=== Caption Renderer Test ===") + + # Test smart_line_break + test_texts = [ + ("AI를 활용한 자동화 방법입니다", 18), + ("단 3가지만 알면 됩니다", 12), + ] + print("\n[1] smart_line_break:") + for text, max_c in test_texts: + lines = smart_line_break(text, max_c) + print(f" 입력: {text!r}") + print(f" 결과: {lines}") + + # Test template lookup + print("\n[2] get_template_for_corner:") + for corner in ['쉬운세상', '숨은보물', '팩트체크', '없는코너']: + tpl = get_template_for_corner(corner) + print(f" {corner}: font_size={tpl.get('font_size')}, animation={tpl.get('animation')}") + + # Test render_captions with dummy timestamps + print("\n[3] render_captions (dry-run):") + sample_timestamps = [ + {'word': '이거', 'start': 0.0, 'end': 0.3}, + {'word': '모르면', 'start': 0.4, 'end': 0.8}, + {'word': '손해입니다', 'start': 0.9, 'end': 1.5}, + ] + sample_script = {'hook': '이거 모르면 손해입니다'} + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) / 'test.ass' + render_captions( + timestamps=sample_timestamps, + script=sample_script, + output_path=out, + corner='쉬운세상', + ) + exists = out.exists() + size = out.stat().st_size if exists else 0 + print(f" ASS 파일 생성: {exists}, 크기: {size}bytes") + assert exists and size > 0, "ASS 파일 생성 실패" + + print("\n✅ 모든 테스트 통과") return ass_path diff --git a/bots/shorts/tts_engine.py b/bots/shorts/tts_engine.py index 10c1ab5..903a5a2 100644 --- a/bots/shorts/tts_engine.py +++ b/bots/shorts/tts_engine.py @@ -525,3 +525,55 @@ def generate_tts( def load_timestamps(ts_path: Path) -> list[dict]: """저장된 타임스탬프 JSON 로드.""" return json.loads(ts_path.read_text(encoding='utf-8')) + + +# ── Standalone test ────────────────────────────────────────────── + +if __name__ == '__main__': + import sys + import tempfile + from pathlib import Path + + if '--test' not in sys.argv: + print("사용법: python -m bots.shorts.tts_engine --test") + sys.exit(0) + + print("=== TTS Engine Test ===") + + # Test SmartTTSRouter initialization + print("\n[1] SmartTTSRouter 초기화:") + router = SmartTTSRouter({'budget': 'free'}) + print(f" budget: {router.budget}") + engine = router.select(text_length=100) + print(f" select(100chars) → {engine}") + assert isinstance(engine, str) and engine, "엔진 선택 실패" + + # Test with medium budget (no API keys → falls back to free engine) + router_med = SmartTTSRouter({'budget': 'medium'}) + engine_med = router_med.select(text_length=500) + print(f" medium budget select(500chars) → {engine_med}") + assert isinstance(engine_med, str) and engine_med, "medium 엔진 선택 실패" + + # Test usage recording + over-limit detection + print("\n[2] 사용량 제한 로직:") + router3 = SmartTTSRouter({'budget': 'free'}) + router3.record_usage('elevenlabs', 9000) # near limit + over = router3._is_over_limit('elevenlabs', 900) # 9000+900 > 8000 threshold + print(f" elevenlabs 9000자 기록 후 900자 추가 → 한도 초과: {over}") + assert over, "한도 초과 감지 실패" + + # Test Edge TTS (always-available free engine) with short text + print("\n[3] Edge TTS 음성 생성 (네트워크 필요):") + with tempfile.TemporaryDirectory() as tmpdir: + try: + wav, timestamps = generate_tts( + script={'hook': '테스트입니다', 'body': [], 'closer': ''}, + output_dir=Path(tmpdir), + timestamp='test_20260329', + ) + print(f" WAV 생성: {wav.exists()}, 타임스탬프: {len(timestamps)}단어") + assert wav.exists(), "WAV 파일 생성 실패" + except Exception as e: + print(f" [경고] TTS 실패 (네트워크/의존성 없을 수 있음): {e}") + + print("\n✅ 모든 테스트 통과") diff --git a/bots/shorts/video_assembler.py b/bots/shorts/video_assembler.py index d35b34f..e2f7347 100644 --- a/bots/shorts/video_assembler.py +++ b/bots/shorts/video_assembler.py @@ -628,3 +628,54 @@ class ResilientAssembler: finally: if tmp_cleanup and work_dir.exists(): shutil.rmtree(work_dir, ignore_errors=True) + + +# ── Standalone test ────────────────────────────────────────────── + +if __name__ == '__main__': + import sys + + if '--test' not in sys.argv: + print("사용법: python -m bots.shorts.video_assembler --test") + sys.exit(0) + + print("=== Video Assembler Test ===") + + # Test GPU encoder detection + print("\n[1] GPU 인코더 자동 감지:") + ffmpeg_bin = _get_ffmpeg() + encoder = _detect_gpu_encoder(ffmpeg_bin) + print(f" 감지된 인코더: {encoder}") + assert encoder in ('h264_nvenc', 'h264_amf', 'h264_qsv', 'libx264'), \ + f"알 수 없는 인코더: {encoder}" + + # Test ResilientAssembler encoder caching + print("\n[2] ResilientAssembler 초기화 + 인코더 캐싱:") + assembler = ResilientAssembler() + enc1 = assembler._get_encoder() + enc2 = assembler._get_encoder() + print(f" 인코더: {enc1}") + assert enc1 == enc2, "캐시 불일치" + assert assembler._encoder is not None, "캐시 저장 실패" + + # Test duration helpers + print("\n[3] 유틸 함수:") + # WAV duration (requires existing file — skip if not present) + try: + import tempfile, wave + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp: + tmp_path = Path(tmp.name) + # Write minimal valid WAV (1s silence at 44100Hz mono) + with wave.open(str(tmp_path), 'w') as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(44100) + wf.writeframes(b'\x00\x00' * 44100) + dur = _get_wav_duration(tmp_path) + print(f" WAV 1초 테스트: duration={dur:.2f}s") + assert abs(dur - 1.0) < 0.1, f"WAV 길이 오류: {dur}" + tmp_path.unlink(missing_ok=True) + except Exception as e: + print(f" [경고] WAV 테스트 건너뜀: {e}") + + print("\n✅ 모든 테스트 통과")