From 84548a326e5085a586d088ae434e5bbf98a32105 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 10 May 2026 18:38:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20cover=2016:9=20landscape=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20+=20=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=94=84=EB=A1=9C=ED=8E=98=EC=85=94=EB=84=90?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- music-lab/app/pipeline/cover.py | 25 +++++++- music-lab/app/pipeline/metadata.py | 96 ++++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/music-lab/app/pipeline/cover.py b/music-lab/app/pipeline/cover.py index e53c592..e5a3037 100644 --- a/music-lab/app/pipeline/cover.py +++ b/music-lab/app/pipeline/cover.py @@ -113,6 +113,25 @@ async def generate(*, pipeline_id: int, genre: str, prompt_template: str, } +def _get_image_size(model: str) -> str: + """모델별 16:9에 가장 가까운 landscape 사이즈. + + OPENAI_IMAGE_SIZE 환경변수로 override 가능. + - gpt-image-1: 1536x1024 (3:2) + - dall-e-3: 1792x1024 (7:4) + - 기타: 1024x1024 (square 폴백) + """ + override = os.getenv("OPENAI_IMAGE_SIZE", "") + if override: + return override + m = (model or "").lower() + if "dall-e-3" in m or "dalle3" in m: + return "1792x1024" + if "gpt-image" in m: + return "1536x1024" + return "1024x1024" + + async def _generate_with_dalle(prompt_template: str, mood: str, feedback: str, out_path: str, *, api_key: str, model: str, @@ -124,13 +143,15 @@ async def _generate_with_dalle(prompt_template: str, mood: str, prompt = f"{prompt}, {mood} mood" if feedback: prompt = f"{prompt}. 추가 지시: {feedback}" - prompt = f"{prompt}, no text, high quality" + # cinematic landscape 명시 — 16:9 영상에 시각적으로 fit하도록 구도 유도 + prompt = f"{prompt}, no text, high quality, cinematic landscape composition, wide aspect" + image_size = _get_image_size(model) async with httpx.AsyncClient(timeout=DALLE_TIMEOUT_S) as client: resp = await client.post( "https://api.openai.com/v1/images/generations", headers={"Authorization": f"Bearer {api_key}"}, - json={"model": model, "prompt": prompt, "size": "1024x1024", "n": 1}, + json={"model": model, "prompt": prompt, "size": image_size, "n": 1}, ) resp.raise_for_status() data = resp.json()["data"][0] diff --git a/music-lab/app/pipeline/metadata.py b/music-lab/app/pipeline/metadata.py index f84fe27..4f459d9 100644 --- a/music-lab/app/pipeline/metadata.py +++ b/music-lab/app/pipeline/metadata.py @@ -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(