# app.py from fastapi import FastAPI, Body, Query from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from typing import List, Optional, Tuple, Literal from transformers import pipeline from datetime import datetime, timedelta import random, re, itertools # --- pytrends optional --- try: from pytrends.request import TrendReq HAS_PYTRENDS = True except Exception: HAS_PYTRENDS = False app = FastAPI(title="NocoAI Title & Prompt Forge (Shorts Optimized)") # ===== 모델 ===== MODEL_ID = "google/mt5-small" pipe = pipeline("text2text-generation", model=MODEL_ID) # ===== 최신 트렌드형 시드 (AI 숏폼 친화 80+개) ===== SEED_TOPICS = [ # ==== 댄스 / 밈 / 첼린지 ==== "AI 미녀가 K-팝 안무를 완벽하게 소화한다면", "AI 걸그룹이 뉴진스 댄스를 커버한다면", "AI 모델이 틱톡 밈 댄스에 도전한다", "AI 캐릭터가 ‘하입보이’에 맞춰 춤춘다면", "AI가 아이돌 오디션에 참가했다면", "AI와 인간이 동시에 춤출 때 생기는 현상", "AI 아이돌의 무대 백업댄서가 인간이라면", "AI 인플루언서가 릴스 첼린지에 참여한다면", "AI가 표정 하나로 밈을 만든다면", "AI가 랜덤 노래 스위치 댄스에 도전한다면", "AI가 유명 댄스 안무를 초슬로로 보여준다면", "AI 캐릭터가 국가별 전통춤을 커버한다면", # ==== 패션 / 뷰티 / 라이프 ==== "AI 모델이 2025 패션위크를 장악한 이유", "AI 미녀의 하루: 출근부터 데이트까지", "AI 인플루언서의 셀카 루틴 (현실감 200%)", "AI가 직접 고른 오늘의 데일리룩", "AI 캐릭터가 꾸민 미니멀 인테리어", "AI 여친의 VLOG: 나랑 하루 살아보기", "AI가 코디한 남자 패션, 현실보다 낫다", "AI 헤어스타일 추천 TOP5", "AI가 알려주는 여름 데이트룩 꿀조합", "AI로 만든 이상형 얼굴, 실제로 보면 이런 느낌", "AI 모델이 패션쇼를 장악한 날", "AI가 계절별 코디 10초 셔플을 보여준다면", # ==== 일상 / 드라마틱 / 리얼루틴 ==== "AI로 바뀐 내 하루, 출근부터 잠들기까지", "AI가 인간의 하루를 완벽하게 복제한다면", "AI가 나 대신 출근하면 생기는 일", "AI 여자친구와 현실 남자의 하루 브이로그", "AI가 집안일을 맡으면 진짜 편할까", "AI가 반려견 산책을 대신하면 벌어지는 일", "AI 인플루언서의 모닝 루틴이 실제로 존재한다면", "AI가 직접 만든 요리, 맛있을까?", "AI가 주식 투자하면 얼마나 벌까?", "AI 비서와 24시간 함께 살아보기", "AI가 나 대신 회의에 참석한다면", "AI가 여행 코스를 하루 만에 짜준다면", # ==== 연애 / 감정 / 리액션 ==== "AI 여자친구가 질투를 느낀다면", "AI 남친이 카톡既読無視 한다면", "AI 캐릭터가 고백을 받아줄까?", "AI와 인간의 첫 데이트 (시뮬레이션)", "AI가 연애 조언을 해준다면 진짜 통할까?", "AI가 울면 진짜 감정일까?", "AI가 사랑을 배운다면 생기는 변화", "AI가 헤어진 연인을 다시 만났을 때", "AI가 내 사진으로 나를 사랑하게 된다면", "AI와 인간이 싸우면 누가 먼저 사과할까?", "AI가 소개팅에서 말실수한다면", "AI가 커플 사진을 재해석해 준다면", # ==== 음식 / 챌린지 / 꿀조합 ==== "편의점 꿀조합 TOP5, 진짜 반칙급 조합 모음", "AI 셰프가 만든 라면 꿀조합", "AI가 음식사진을 보고 레시피를 만든다면", "AI 먹방: 인간보다 더 리얼하게 먹는다", "AI 캐릭터가 카페 음료 추천해준다", "AI가 가장 좋아하는 간식 TOP3", "AI 다이어트 루틴, 현실보다 정확하다", "AI가 요리하면 진짜 맛있을까?", "AI가 고른 편의점 도시락 1등 메뉴", "AI가 한국 음식 처음 먹었을 때 반응", "AI가 10분 만에 홈카페를 구현한다면", "AI가 세계 길거리 음식을 재현한다면", # ==== 학습 / 자기계발 / 지식 ==== "한 달 만에 영어 리스닝 폭발적으로 올리는 법", "AI가 알려주는 공부 집중력 루틴", "AI가 직접 설계한 ‘기억력 훈련법’", "AI가 코딩 배워서 만든 첫 프로젝트", "AI 튜터와 함께 영어회화 연습하기", "AI가 스마트폰 중독을 고치는 법", "AI가 인간보다 빠르게 책 요약하는 법", "AI가 만든 시험 대비 공부 루틴", "AI가 뇌 과학으로 집중력을 높인다", "AI 선생님이 알려주는 토익 리스닝 비법", "AI가 하루 10분 스피킹 훈련 루틴을 짜준다면", # ==== 사회 / 뉴스 / 반전 ==== "AI가 만든 가짜 뉴스, 당신은 속을까", "AI 캐릭터가 대통령에 출마한다면", "AI 기자가 직접 뉴스 진행을 맡는다면", "AI가 인플루언서를 대체한 세상", "AI가 만든 광고, 진짜보다 설득력 있다", "AI 캐릭터가 토론 프로그램에 나온다면", "AI 모델이 SNS를 점령하는 이유", "AI가 선거에 개입한다면 생길 일", "AI의 감정 표현이 진짜처럼 보일 때", "특이점이 온 AI 세상, 웃기지만 무섭다", "AI로 만든 영화 예고편이 진짜 영화보다 멋지다", "갤럭시 vs 아이폰 카메라, 진짜 차이 1초 비교", "AI 아이돌이 부르는 K-POP 커버 무대", "AI로 만든 가상 아이돌, 진짜보다 예쁜 이유", ] _seed_iter = itertools.cycle(SEED_TOPICS) # ===== 요청/응답 ===== class GenerateReq(BaseModel): mode: Optional[str] = Field(None, description="auto | topic") topic: Optional[str] = None count: int = Field(30, ge=1, le=80, description="생성할 제목 수(최대 80)") # 언어/국가 (현지화) lang: str = Field("한국어", description="예: 한국어/Korean/ko/ko-KR/English/en/zh 등") country: Optional[str] = Field(None, description="예: KR, US, JP, ES, MX, CN 등. 없으면 언어에 맞춰 추론") length: str = Field("medium", description="short | medium | long") style: str = Field("meme-shock", description="meme-shock | wholesome | informative | clickbait") emoji: int = Field(1, ge=0, le=2) numberless: bool = Field(True) extra_hint: Optional[str] = None trend_source: str = Field("pytrends", description="pytrends | seed") passes: int = Field(3, ge=1, le=6, description="다양성 확보용 샘플링 횟수") class GenerateResp(BaseModel): topic: str titles: List[str] used_prompt: str trend_source: str class TopicResp(BaseModel): topics: List[str] lang: str country: str category: Optional[str] = None class ShortsPromptReq(BaseModel): topic: str = Field(..., description="주제(예: 편의점 꿀조합 TOP5)") title: str = Field(..., description="선정된 제목(한 줄)") lang: str = Field("ko") style: str = Field("meme-shock") tone: Optional[str] = Field(None, description="energetic | wholesome | informative | edgy 등") duration_sec: int = Field(30, ge=15, le=60) cuts: int = Field(5, ge=5, le=8) emoji: int = Field(1, ge=0, le=2) class ShortsPromptResp(BaseModel): topic: str title: str prompt: str lang: str style: str cuts: int duration_sec: int # ===== 언어/국가 정규화 ===== LANG_MAP = { "ko": ("ko", "한국어"), "ko-kr": ("ko", "한국어"), "korean": ("ko", "한국어"), "한국어": ("ko", "한국어"), "en": ("en", "English"), "en-us": ("en", "English"), "english": ("en", "English"), "영어": ("en", "English"), "ja": ("ja", "日本語"), "ja-jp": ("ja", "日本語"), "japanese": ("ja", "日本語"), "일본어": ("ja", "日本語"), "es": ("es", "Español"), "es-es": ("es", "Español"), "es-mx": ("es", "Español"), "spanish": ("es", "Español"), "스페인어": ("es", "Español"), "zh": ("zh", "中文"), "zh-cn": ("zh", "中文"), "chinese": ("zh", "中文"), "중국어": ("zh", "中文"), "中文": ("zh", "中文"), } PNT_MAP = { "KR": ("south_korea", "ko-KR"), "US": ("united_states", "en-US"), "JP": ("japan", "ja-JP"), "ES": ("spain", "es-ES"), "MX": ("mexico", "es-419"), "CN": ("china", "zh-CN"), # pytrends 미지원/불안정 가능성 있음 } LANG_DEFAULT_COUNTRY = {"ko": "KR", "en": "US", "ja": "JP", "es": "ES", "zh": "CN"} def normalize_lang_and_country(lang_in: str, country_in: Optional[str]) -> Tuple[str, str, str]: key = (lang_in or "").strip().lower().replace("_", "-") if key in LANG_MAP: lang_code, pretty = LANG_MAP[key] else: lang_code, pretty = ("ko", "한국어") country = (country_in or "").upper().strip() or LANG_DEFAULT_COUNTRY.get(lang_code, "US") if country not in PNT_MAP: country = LANG_DEFAULT_COUNTRY.get(lang_code, "US") return lang_code, pretty, country # ===== 현지화 리소스 ===== EXAMPLES = { "ko": ("예시(형식만 참고):\n" "아이폰도 흔들린 갤럭시 기능, 이건 몰랐죠?\n" "AI로 1분 컷 영상 만들기… 실화냐 🤯\n" "편의점 조합 TOP5, 마지막이 미쳤다 ㄷㄷ\n"), "en": ("Examples (format only):\n" "iPhone users switching? This Galaxy feature is wild 🤯\n" "Make a viral short in 60s… actually works\n" "Top 5 convenience-store hacks (the last one is insane)\n"), "ja": ("例(形式のみ参考):\n" "iPhone派も揺れたGalaxy機能、知ってた?\n" "AIで60秒ショート作成…マジか 🤯\n" "コンビニ神アレンジTOP5、最後がヤバい\n"), "es": ("Ejemplos (solo formato):\n" "¿Usuarios de iPhone dudando? Esta función de Galaxy es una locura 🤯\n" "Crea un short viral en 60s… funciona de verdad\n" "Top 5 trucos de tienda, el último te vuela la cabeza\n"), "zh": ("示例(仅供格式参考):\n" "iPhone用户都震惊了!这项Galaxy功能太离谱了 🤯\n" "AI一分钟做爆款短视频,真的假的?\n" "便利店神组合TOP5,最后一个直接炸裂!\n"), } HOOKS = { "ko": ["이건 몰랐죠?", "소름", "충격", "실화냐", "TOP5", "ㄹㅇ", "대박", "미쳤다", "반전 있음", "한줄 요약", "지금 안 보면 손해", "30초 컷", "끝까지 보세요", "알고 보면 더 소름", "초간단"], "en": ["did you know?", "shocking", "insane", "is this real?", "TOP 5", "no way", "viral", "mind-blown", "plot twist", "one-line tip", "don’t miss this", "30s hack", "watch till the end", "low-key genius"], "ja": ["知ってた?", "鳥肌", "衝撃", "マジ?", "TOP5", "やばい", "バズる", "神", "まさかの展開", "要点だけ", "見逃し厳禁", "30秒でOK", "最後まで見て", "実はコレ"], "es": ["¿lo sabías?", "impactante", "una locura", "¿es real?", "TOP 5", "no puede ser", "viral", "mente en blanco", "giro inesperado", "tip en una línea", "no te lo pierdas", "truco de 30s", "hasta el final", "dato clave"], "zh": ["震惊", "太离谱了吧", "你绝对想不到", "TOP5", "网友都惊呆了", "太炸了", "笑死", "破防了", "这操作逆天", "真敢拍", "不看后悔", "细思极恐", "绝了", "太狠了"], } TAILS = { "ko": ["방법 공개", "시청자 후기 터짐", "전/후 비교", "초보도 가능", "숨은 기능", "반전 주의", "실전 꿀팁", "모르면 손해", "한 번에 끝", "바로 써먹는 스킬", "핵심만 요약", "정리해드림"], "en": ["method revealed", "viewers went crazy", "before vs after", "beginner friendly", "hidden feature", "twist ahead", "pro tips", "don’t miss out", "done in one go", "instant skill", "key points only", "quick recap"], "ja": ["やり方公開", "視聴者の反応が神", "ビフォー/アフター", "初心者OK", "隠し機能", "ネタバレ注意", "実用テク", "知らなきゃ損", "一発で完了", "すぐ使えるワザ", "要点だけ", "まとめて解説"], "es": ["método revelado", "los viewers enloquecen", "antes vs después", "para principiantes", "función oculta", "plot twist", "tips pro", "no te lo pierdas", "todo de una", "skill al instante", "puntos clave", "resumen rápido"], "zh": ["完整版来了", "网友热议中", "看完我沉默了", "前后对比离谱", "新手也能做", "隐藏功能曝光", "背后原因震撼", "不看真的亏", "教程来了", "全网爆火", "亲测有效", "干货总结"], } PROMPT_HEADER = { "ko": "역할: 당신은 해당 국가의 유튜브 쇼츠 카피라이터다.\n과업: 아래 ‘주제’로 현지 시청자가 많이 보게끔 바이럴 제목을 생성하라.\n", "en": "Role: You are a YouTube Shorts copywriter for the target country.\nTask: Generate viral titles so local viewers want to watch.\n", "ja": "役割: あなたは対象国向けのYouTubeショートコピーライターです。\n課題: 現地視聴者が見たくなるバイラルタイトルを作成してください。\n", "es": "Rol: Eres copywriter de YouTube Shorts para el país objetivo.\nTarea: Genera títulos virales para atraer a los espectadores locales.\n", "zh": "角色: 你是一名面向中国观众的YouTube Shorts文案创作者。\n任务: 根据主题生成能让当地观众停下滑动、点开观看的爆款标题。\n", } def length_rule(length: str, lang_code: str) -> str: if lang_code in ("ko", "ja", "zh"): return {"short": "각 제목은 10~18자 (임팩트)", "medium": "각 제목은 16~28자 (짧고 강렬)", "long": "각 제목은 24~38자 (반전/스토리)"}\ .get(length, "각 제목은 16~28자") else: return {"short": "Each title 3–6 words (punchy)", "medium": "Each title 5–9 words (concise, strong hook)", "long": "Each title 8–14 words (with twist/story)"}\ .get(length, "Each title 5–9 words") def style_block(style: str, lang_code: str) -> str: blocks = { "ko": {"meme-shock": "- 밈/반전/감정 자극 적극 활용\n- ㅋㅋㅋ, ㄷㄷ, ㅠㅠ, 🤯 허용\n- 호기심/충격 단어 적극 사용", "wholesome": "- 공감/힐링 중심, 따뜻한 어휘", "informative": "- 핵심 키워드 중심, 신뢰/간결", "clickbait": "- 과장/미스터리 톤, 궁금증 유발(사실 왜곡 금지)"}, "en": {"meme-shock": "- Use memes/twist/emotion\n- Allow 🤯/omg vibe\n- Strong curiosity/shock words", "wholesome": "- Relatable/warm tone", "informative": "- Key facts first, concise & credible", "clickbait": "- Tease mystery, no deception"}, "ja": {"meme-shock": "- ネタ/ギャップ/感情刺激を活用\n- 🤯/やばい 等OK\n- 好奇心・驚きワードを強めに", "wholesome": "- 共感・ほっこり系の語彙", "informative": "- 要点重視、簡潔", "clickbait": "- ミステリー調で引き、誇張はしつつも誤情報禁止"}, "es": {"meme-shock": "- Usa memes/giros/emoción\n- Se permite 🤯 vibes\n- Palabras de curiosidad/sorpresa fuertes", "wholesome": "- Tono cercano y cálido", "informative": "- Datos clave primero, conciso y fiable", "clickbait": "- Genera misterio sin engaño"}, "zh": {"meme-shock": "- 强化梗/反转/情绪张力\n- 允许 🤯/离谱/无语 等语气\n- 使用强烈好奇/震惊词", "wholesome": "- 共情/治愈风,语气温暖", "informative": "- 关键信息优先,简洁可信", "clickbait": "- 吊足胃口,避免虚假夸大"}, } return blocks.get(lang_code, {}).get(style, "") def build_prompt(lang_code: str, pretty_lang: str, topic: str, count: int, length: str, style: str, emoji: int, numberless: bool, extra_hint: Optional[str]) -> str: example = EXAMPLES.get(lang_code, EXAMPLES["ko"]) header = PROMPT_HEADER.get(lang_code, PROMPT_HEADER["ko"]) rules = [ f"- 주제/Topic: {topic}", "- 유튜브 쇼츠용 바이럴 제목만 생성 / Generate only viral titles for Shorts", "- 강력한 훅(첫 5자)·호기심 갭·반전/감탄 포함 / Strong hook, curiosity gap, twist", length_rule(length, lang_code), f"- 이모지는 최대 {emoji}개 / Up to {emoji} emoji(s)", "- 번호·불릿·따옴표·설명 없이, 제목만 한 줄씩 / No numbers/quotes/explanations", "- 서로 다른 아이디어로 다양하게(중복 금지) / Ensure diversity, no duplicates", "- 과도한 욕설/선정성·저작권/명예훼손 금지 / No slurs/adult content/defamation", style_block(style, lang_code), (f"- 추가 힌트 / Extra hint: {extra_hint}" if extra_hint else ""), f"- 반드시 총 {count}줄 출력 / Exactly {count} lines", ] rule_text = "\n".join([r for r in rules if r]) return f"{header}{rule_text}\n\n{example}\n출력/Output: 제목만 줄바꿈으로 나열 / One title per line.\n" # ===== 트렌드 캐시/조회 ===== _cache = {"topics": [], "ts": datetime.min, "key": ""} def fetch_trends_pytrends(n: int, country: str) -> List[str]: if not HAS_PYTRENDS: return [] pn, hl = PNT_MAP.get(country, ("south_korea", "ko-KR")) cache_key = f"{pn}:{hl}" global _cache if _cache["topics"] and _cache["key"] == cache_key and datetime.utcnow() - _cache["ts"] < timedelta(minutes=10): return _cache["topics"][:n] try: pt = TrendReq(hl=hl, tz=540) df = pt.trending_searches(pn=pn) topics = [x for x in df[0].tolist() if isinstance(x, str)] _cache = {"topics": topics, "ts": datetime.utcnow(), "key": cache_key} return topics[:n] except Exception: return [] def pick_auto_topic(source: str, country: str) -> str: if source == "pytrends" and HAS_PYTRENDS: topics = fetch_trends_pytrends(8, country) if topics: return random.choice(topics) base = next(_seed_iter) pool = [base] + random.sample(SEED_TOPICS, k=min(3, len(SEED_TOPICS))) return random.choice(pool) # ===== 제목 후처리(강화) ===== _LEAK_PATTERNS = [ r'^\s*(주제|topic)\s*[:/]', r'^\s*(출력|output)\s*[:/]', r'^\s*예시', r'생성하라[.!?]?\s*$', r'^\s*[-•]\s*$', r'^\s*각 제목은', ] def _fingerprint(s: str) -> str: return re.sub(r"[\s\W]+", "", s.lower()) def _valid_len(s: str, lang_code: str) -> bool: if lang_code in ("en", "es"): words = re.findall(r"\w+", s) return 3 <= len(words) <= 14 return 6 <= len(s) <= 36 # ko/ja/zh def _leak(s: str) -> bool: for p in _LEAK_PATTERNS: if re.search(p, s, flags=re.I): return True return False def postprocess(raw: str, lang_code: str, numberless: bool = True) -> List[str]: if not raw: return [] raw = re.sub(r"", "", raw) out = [] for line in raw.splitlines(): s = line.strip() if not s: continue if numberless: s = re.sub(r'^[\-\*\d]+\s*[\.\)]?\s*', '', s) s = re.sub(r'^[\"\':“”‘’]+', '', s).strip() if _leak(s): continue if _valid_len(s, lang_code): out.append(s) seen, dedup = set(), [] for s in out: key = _fingerprint(s) if key not in seen: seen.add(key) dedup.append(s) return dedup def generate_titles(topic: str, count: int, req, lang_code: str, pretty_lang: str) -> Tuple[List[str], str]: base_params = [(0.9,0.9),(1.0,0.92),(0.85,0.95),(1.05,0.88),(0.95,0.97),(1.1,0.9)] param_grid = base_params[:max(1, min(req.passes, len(base_params)))] collected, used_prompts = [], [] for (temperature, top_p) in param_grid: prompt = build_prompt(lang_code, pretty_lang, topic, count, req.length, req.style, req.emoji, req.numberless, req.extra_hint) gen = pipe( prompt, max_new_tokens=180 if req.length in ("medium","long") else 120, temperature=float(temperature), top_p=float(top_p), do_sample=True, repetition_penalty=1.12, no_repeat_ngram_size=3, ) text = gen[0].get("generated_text", "").strip() used_prompts.append(prompt) batch = postprocess(text, lang_code, numberless=req.numberless) collected.extend(batch) seen, dedup = set(), [] for s in collected: key = _fingerprint(s) if key not in seen: seen.add(key) dedup.append(s) if len(dedup) < count: hooks = HOOKS.get(lang_code, HOOKS["ko"]) tails = TAILS.get(lang_code, TAILS["ko"]) templates = [] for _ in range(count * 2): h = random.choice(hooks); t = random.choice(tails) if lang_code in ("en", "es"): templates.append(f"{topic}: {h}, {t}") else: templates.append(f"{topic} {h} {t}") for s in templates: if not _valid_len(s, lang_code): continue key = _fingerprint(s) if key not in seen: seen.add(key); dedup.append(s) if len(dedup) >= count: break return dedup[:count], (used_prompts[-1] if used_prompts else "") # ===== 토픽 포지(영상화 가능한 주제 자동 생성) ===== TOPIC_CATEGORIES = ("ai-slice","ai-teach","ai-transform","ai-battle","ai-micro","mixed") OBJ = { "ko": { "fruit": ["수박","망고","파인애플","사과","키위","용과","복숭아","바나나","코코넛","포도"], "planet": ["지구","화성","토성","목성","달","금성","해왕성","천왕성","태양(미니버전)"], "animal": ["고양이","강아지","판다","수달","앵무새","고슴도치","카피바라"], "item": ["루빅스큐브","초콜릿바","라면","떡볶이","스니커즈","축구공","드론"], "subject": ["영어","한국어","수학","춤","요리","게임","드로잉"], "elder": ["할머니","할아버지"], "persona": ["외국인","로봇","마법사","슈퍼히어로","AI 비서","유튜버"], "micro": ["미세먼지","세포","바이러스","박테리아","물방울","설탕 결정"], }, "en": { "fruit": ["watermelon","mango","pineapple","apple","kiwi","dragon fruit","peach","banana","coconut","grapes"], "planet": ["Earth","Mars","Saturn","Jupiter","Moon","Venus","Neptune","Uranus","mini Sun"], "animal": ["cat","dog","panda","otter","parrot","hedgehog","capybara"], "item": ["Rubik’s cube","chocolate bar","ramen","spicy rice cake","sneakers","soccer ball","drone"], "subject": ["English","Korean","Math","Dance","Cooking","Gaming","Drawing"], "elder": ["grandma","grandpa"], "persona": ["foreigner","robot","wizard","superhero","AI assistant","YouTuber"], "micro": ["fine dust","cells","virus","bacteria","droplet","sugar crystal"], }, } def _obj(lang: str) -> dict: return OBJ.get(lang, OBJ["ko"]) def forge_topic(lang_code: str, category: str) -> str: o = _obj(lang_code) if category == "ai-slice": thing = random.choice(o["fruit"] + o["planet"] + o["item"]) return f"AI가 {thing}를 레이저 칼로 자르는 영상" if category == "ai-teach": who = random.choice(o["persona"] + o["elder"]) subj = random.choice(o["subject"]) return f"{who}가 유머러스하게 {subj}를 가르치는 15초 쇼츠" if category == "ai-transform": a = random.choice(o["animal"] + o["item"]) b = random.choice(o["fruit"] + o["planet"]) return f"AI가 {a}를 {b}(으)로 변신시키는 타임랩스" if category == "ai-battle": a = random.choice(o["animal"] + o["item"]) b = random.choice(o["planet"] + o["fruit"]) return f"AI가 {a} vs {b} 대결을 시뮬레이션하는 영상" if category == "ai-micro": m = random.choice(o["micro"]) t = random.choice(o["fruit"] + o["item"]) return f"AI 마이크로 세계 탐험: {t} 속 {m} 확대 관찰" return forge_topic(lang_code, random.choice(TOPIC_CATEGORIES[:-1])) # mixed # ===== 쇼츠 프롬프트(샷리스트) 생성 ===== def build_shorts_prompt(topic: str, title: str, lang_code: str, style: str, tone: Optional[str], cuts: int, duration_sec: int, emoji: int) -> str: # 컷 개수 보정 cuts = max(5, min(8, cuts)) per_cut = max(4, min(7, duration_sec // cuts)) # 컷당 초 style_note = { "meme-shock": "강한 훅 + 반전 + 리듬. 자막 친화. 짧은 구/명령문.", "wholesome": "공감/힐링 톤. 친절한 말투. 희망/응원.", "informative": "핵심 먼저, 팩트 중심. 간결/신뢰.", "clickbait": "궁금증 유발, 과장 허용(사실 왜곡 금지).", }.get(style, "리듬·호기심 중심.") tone_note = f"추가 톤: {tone}." if tone else "" emj = " 이모지 적당히 사용" if emoji > 0 else " 이모지 사용 금지" header = ( f"역할: 유튜브 쇼츠 콘텐츠 플래너 & 카피라이터\n" f"목표: 아래 '제목'을 기반으로 {duration_sec}초 이내 영상 프롬프트(대본 지시문)를 생성한다.\n" f"규칙:\n" f"- 초반 3초 강한 훅, 3~4개의 핵심 포인트, 마지막에 액션콜(좋아요/댓글 유도)\n" f"- 짧고 리듬감 있게, 자막용 문장\n" f"- 컷(장면)을 번호로 구분, 각 컷 1~2문장\n" f"- 촬영 소스 없어도 생성형 영상툴에 바로 넣을 수 있는 묘사 포함\n" f"- 스타일: {style} ({style_note}), 언어: {lang_code}, {tone_note}{emj}\n" f"- 컷 수: {cuts}개, 컷당 {per_cut}초 내외\n\n" f"메타:\n" f"• 주제(Topic): {topic}\n" f"• 제목(Title): {title}\n\n" f"출력 포맷 예시(형식만 참고):\n" f"1) 훅: 시청자 호기심 폭발 한 줄\n" f"2) 포인트1: 구체 예시/브랜드/가격 등\n" f"3) 포인트2: 비교/반전/주의\n" f"4) 포인트3: 실전 팁/바로 써먹기\n" f"5) 마무리: 한 줄 요약 + 액션콜(댓글/좋아요 유도)\n\n" f"이제 위 규칙을 철저히 지켜 {cuts}개의 컷으로 한국어 프롬프트를 출력하라." ) return header # ===== 엔드포인트 ===== @app.get("/topic/auto", response_model=TopicResp) def topic_auto( count: int = Query(10, ge=1, le=100), lang: str = Query("ko"), country: Optional[str] = Query(None), category: Optional[Literal["ai-slice","ai-teach","ai-transform","ai-battle","ai-micro","mixed"]] = Query("mixed") ): lang_code, pretty_lang, country_code = normalize_lang_and_country(lang, country) cats = [category] if category and category != "mixed" else None topics = [] if HAS_PYTRENDS: trendy = fetch_trends_pytrends(min(5, count), country_code) topics.extend(trendy) while len(topics) < count: cat = random.choice(cats) if cats else random.choice(TOPIC_CATEGORIES[:-1]) topics.append(forge_topic(lang_code, cat)) seen, uniq = set(), [] for t in topics: k = re.sub(r"\s+", "", t.lower()) if k not in seen: seen.add(k) uniq.append(t) if len(uniq) >= count: break return JSONResponse({"topics": uniq, "lang": pretty_lang, "country": country_code, "category": category}, media_type="application/json; charset=utf-8") @app.get("/") def root(): return JSONResponse({"message": "NocoAI Title & Prompt Forge. Try GET /topic/auto, POST /generate, GET /generate/auto, POST /prompt/shorts"}, media_type="application/json; charset=utf-8") @app.get("/health") def health(): return JSONResponse({"status": "ok", "model": MODEL_ID, "cache_size": len(_cache.get("topics", []))}, media_type="application/json; charset=utf-8") @app.post("/generate", response_model=GenerateResp) def generate(req: Optional[GenerateReq] = Body(default=None)): if req is None: req = GenerateReq() lang_code, pretty_lang, country = normalize_lang_and_country(req.lang, req.country) if req.topic and (req.mode is None or req.mode == "auto"): mode = "topic" else: mode = req.mode or ("topic" if req.topic else "auto") if mode == "topic" and req.topic: topic = req.topic; used_source = "custom" else: topic = pick_auto_topic(req.trend_source, country) used_source = req.trend_source if (req.trend_source == "pytrends" and HAS_PYTRENDS) else "seed" titles, used_prompt = generate_titles(topic, req.count, req, lang_code, pretty_lang) return JSONResponse({"topic": topic, "titles": titles, "used_prompt": used_prompt, "trend_source": used_source}, media_type="application/json; charset=utf-8") @app.get("/generate/auto", response_model=GenerateResp) def generate_auto( count: int = Query(30, ge=1, le=80), length: str = Query("medium"), style: str = Query("meme-shock"), emoji: int = Query(1, ge=0, le=2), numberless: bool = Query(True), trend_source: str = Query("pytrends"), passes: int = Query(3, ge=1, le=6), lang: str = Query("ko"), country: Optional[str] = Query(None), ): lang_code, pretty_lang, country_code = normalize_lang_and_country(lang, country) class _Req: pass req = _Req() req.lang = pretty_lang; req.length = length; req.style = style; req.emoji = emoji req.numberless = numberless; req.extra_hint = None; req.trend_source = trend_source; req.passes = passes topic = pick_auto_topic(trend_source, country_code) titles, used_prompt = generate_titles(topic, count, req, lang_code, pretty_lang) used_source = trend_source if (trend_source == "pytrends" and HAS_PYTRENDS) else "seed" return JSONResponse({"topic": topic, "titles": titles, "used_prompt": used_prompt, "trend_source": used_source}, media_type="application/json; charset=utf-8") @app.post("/prompt/shorts", response_model=ShortsPromptResp) def prompt_shorts(body: ShortsPromptReq): lang_code, _, _ = normalize_lang_and_country(body.lang, None) prompt = build_shorts_prompt( topic=body.topic, title=body.title, lang_code=lang_code, style=body.style, tone=body.tone, cuts=body.cuts, duration_sec=body.duration_sec, emoji=body.emoji ) return JSONResponse({ "topic": body.topic, "title": body.title, "prompt": prompt, "lang": lang_code, "style": body.style, "cuts": body.cuts, "duration_sec": body.duration_sec }, media_type="application/json; charset=utf-8")