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:
@@ -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자 이내, 5–7 섹션, 자연스러운 톤)")
|
||||
parts.append("1. **한 문장 후크** — 누구를 위한 무엇인지 (예: '집중과 휴식이 필요한 모든 순간을 위한 차분한 lo-fi 컴필레이션.')")
|
||||
parts.append("2. **분위기 묘사** (2–3 문장) — 시각적 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 #공부음악 등 5–10개 (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(
|
||||
|
||||
Reference in New Issue
Block a user