feat(music-lab): cover 16:9 landscape 생성 + 메타데이터 프로페셔널화

- cover.py: DALL·E 3 → 1792x1024, gpt-image-1 → 1536x1024 (모델별 자동),
  prompt에 'cinematic landscape composition' 명시. OPENAI_IMAGE_SIZE env로 override 가능.
- metadata.py: prompt를 list+join 패턴으로 재구성 (인접 문자열/+ 충돌 해결)
  + lofi 채널 카피라이터 페르소나 부여. description 5-7섹션 구조 명시:
  후크/분위기/사용시나리오/챕터/시청권장/콜투액션/해시태그.
  mix vs single 분기 + tags 가이드 + 출력 JSON schema 명시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:38:53 +09:00
parent 5f5010ded4
commit 84548a326e
2 changed files with 101 additions and 20 deletions

View File

@@ -78,27 +78,87 @@ def _fallback_template(track: dict, template: dict, tracks: list[dict] | None =
}
def _build_prompt(track: dict, template: dict, trend_keywords: list[str],
feedback: str, tracks: list[dict] | None) -> str:
"""프로페셔널 lofi/ambient 채널 수준의 메타데이터 작성 prompt.
list + join 패턴으로 조립 — 인접 문자열 리터럴/+ 충돌 회피, 한글 인코딩 안전.
"""
is_mix = bool(tracks and len(tracks) > 1)
parts: list[str] = []
# === 입력 ===
parts.append("당신은 lo-fi/ambient YouTube 채널의 카피라이터입니다.")
parts.append("프로페셔널 메타데이터를 작성하세요. JSON으로만 응답.")
parts.append("")
parts.append("## 입력")
parts.append(f"트랙: {json.dumps(track, ensure_ascii=False)}")
parts.append(f"사용자 템플릿(참고용, 이 정보 포함하되 단순 치환은 X): {json.dumps(template, ensure_ascii=False)}")
trend_str = ", ".join(trend_keywords) if trend_keywords else "(없음)"
parts.append(f"트렌드 키워드: {trend_str}")
if is_mix:
chapters = _format_chapters(tracks)
parts.append("")
parts.append(f"이 영상은 {len(tracks)}개 트랙의 **mix 컴필레이션**입니다. 챕터 리스트:")
parts.append(chapters)
if feedback:
parts.append("")
parts.append(f"사용자 피드백 (이 방향으로 수정): {feedback}")
# === title 가이드 ===
parts.append("")
parts.append("## title (60자 이내, 클릭률 + SEO)")
if is_mix:
parts.append("- mix면 '시간 + 분위기 + 사용 시나리오' 형식. 예: '1 Hour Lo-Fi Chill Mix — Late Night Study & Relaxation'")
else:
parts.append("- 단일 트랙이면 '제목 — 분위기' 또는 '[장르] 제목 ({BPM}BPM)' 형식")
parts.append("- 이모지 1개 정도 OK (🎧 📻 ☕ 🌙 등). 과한 이모지 X")
parts.append("- 영문 + 한글 혼용 자연스럽게")
# === description 가이드 ===
parts.append("")
parts.append("## description (1500자 이내, 57 섹션, 자연스러운 톤)")
parts.append("1. **한 문장 후크** — 누구를 위한 무엇인지 (예: '집중과 휴식이 필요한 모든 순간을 위한 차분한 lo-fi 컴필레이션.')")
parts.append("2. **분위기 묘사** (23 문장) — 시각적 imagery 포함 (예: '비 오는 카페 창가의 따뜻한 조명처럼')")
parts.append("3. **추천 사용 상황** — 공부, 작업, 코딩, 명상, 운전, 카페 BGM 등 구체적 시나리오")
if is_mix:
parts.append("4. **트랙 리스트 / 챕터** — 위 챕터 리스트를 그대로 포함 (YouTube 자동 챕터 인식). 각 줄에 `[mm:ss] 제목` 형식 유지")
else:
parts.append("4. **음향 정보** — 장르, BPM, Key 등 트랙 정보를 자연스럽게 풀어서 설명")
parts.append("5. **시청 권장사항** — '🎧 헤드폰으로 들으시면 더 좋은 사운드를 경험하실 수 있습니다' 같은 안내")
parts.append("6. **콜투액션** — 구독, 좋아요, 댓글로 분위기/요청 공유 유도. 자연스럽고 짧게")
parts.append("7. **(선택) 해시태그** — 끝에 #lofi #studymusic #공부음악 등 510개 (description 본문 안)")
parts.append("- 톤: 차분하고 진정성 있는 lofi 채널 큐레이터. 광고스러운 과장 X")
# === tags 가이드 ===
parts.append("")
parts.append("## tags (15개 이내, SEO)")
parts.append("- 영문 키워드 우선: lofi, lo-fi, chill beats, study music, work music, ambient, instrumental, relaxing, focus, night vibes 등")
parts.append("- 한글 보조: 공부음악, 작업음악, 카페음악, 집중력, 잔잔한 음악 등")
parts.append("- 트렌드 키워드(있으면) 반드시 포함")
if is_mix:
parts.append("- 'mix', 'compilation', 'long mix' 같은 mix 특화 태그 추가")
# === category + 출력 ===
parts.append("")
parts.append("## category_id")
parts.append("10 (Music) 고정")
parts.append("")
parts.append("## 출력")
parts.append("```json")
parts.append('{"title": "...", "description": "...", "tags": [...], "category_id": 10}')
parts.append("```")
parts.append("JSON 외 다른 텍스트는 출력 X.")
return "\n".join(parts)
async def _call_claude(track: dict, template: dict, trend_keywords: list[str],
feedback: str, tracks: list[dict] | None,
*, api_key: str, model: str) -> dict:
user_prompt = (
"다음 트랙의 YouTube 메타데이터를 생성하세요. JSON으로만 응답.\n\n"
f"트랙: {json.dumps(track, ensure_ascii=False)}\n"
f"템플릿: {json.dumps(template, ensure_ascii=False)}\n"
f"트렌드 키워드: {', '.join(trend_keywords)}\n"
)
if tracks and len(tracks) > 1:
chapters = _format_chapters(tracks)
user_prompt += (
f"\n이 영상은 {len(tracks)}개 트랙의 mix입니다. "
f"description에 다음 챕터 리스트를 그대로 포함하세요 (YouTube 자동 챕터 인식용):\n{chapters}\n"
)
if feedback:
user_prompt += f"\n사용자 피드백: {feedback}\n"
user_prompt += (
'\n출력 JSON: {"title": "60자 이내", "description": "1000자 이내",'
' "tags": ["15개 이내"], "category_id": 10}'
)
user_prompt = _build_prompt(track, template, trend_keywords, feedback, tracks)
async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
resp = await client.post(