feat: v2 파이프라인 — 매일 2곡 다양한 장르 BGM 자동 생성/업로드
- daily_precache.py: Claude Code CLI 프리캐시 (날짜/계절/기념일 테마) - daily_scheduler.py: ACE-Step 음악 → FLUX 이미지 → 영상 렌더 → 큐 - upload_scheduled.py: auto_shorts 동일 큐 방식 업로드 - PRECACHE_GUIDE_MUSIC.md: 19개 장르, 감성 제목, 재생목록 자유 생성 - generate_image.py: --lowvram 제거, GPU VRAM 확인 추가 - config.py: @animily-music 토큰 경로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
223
daily_precache.py
Executable file
223
daily_precache.py
Executable file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
daily_precache.py — Claude Code CLI로 음악 트랙 2개 기획 JSON 생성
|
||||
|
||||
Usage:
|
||||
python daily_precache.py [--date 2026-05-25]
|
||||
|
||||
Output:
|
||||
precache/{date}.json
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
|
||||
# Project paths
|
||||
PROJECT_DIR = Path("/home/javamon/project/animily_music")
|
||||
PRECACHE_DIR = PROJECT_DIR / "precache"
|
||||
GUIDE_PATH = PROJECT_DIR / "PRECACHE_GUIDE_MUSIC.md"
|
||||
LOG_DIR = PROJECT_DIR / "logs"
|
||||
|
||||
# Logging
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(LOG_DIR / "precache.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_target_date(args=None):
|
||||
"""Parse target date from args or use tomorrow."""
|
||||
if args and "--date" in args:
|
||||
idx = args.index("--date")
|
||||
if idx + 1 < len(args):
|
||||
return args[idx + 1]
|
||||
# Default: tomorrow
|
||||
from datetime import timedelta
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
return tomorrow.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def build_prompt(target_date: str) -> str:
|
||||
"""Build the prompt for Claude Code CLI."""
|
||||
d = datetime.strptime(target_date, "%Y-%m-%d")
|
||||
weekday_kr = ["월", "화", "수", "목", "금", "토", "일"][d.weekday()]
|
||||
month = d.month
|
||||
|
||||
# Season
|
||||
if month in (3, 4, 5):
|
||||
season = "봄"
|
||||
elif month in (6, 7, 8):
|
||||
season = "여름"
|
||||
elif month in (9, 10, 11):
|
||||
season = "가을"
|
||||
else:
|
||||
season = "겨울"
|
||||
|
||||
prompt = f"""You are a music curator for the ANIMILY YouTube channel.
|
||||
Generate a JSON plan for 2 BGM tracks to be published on {target_date} ({weekday_kr}요일).
|
||||
|
||||
Season: {season}
|
||||
Date context: {target_date} ({weekday_kr}요일)
|
||||
|
||||
Read the guide at: {GUIDE_PATH}
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Output ONLY valid JSON (no markdown, no explanation, no code blocks)
|
||||
2. The 2 tracks MUST be different genres
|
||||
3. Caption focuses on style/mood/instruments only (NOT tempo/key info)
|
||||
4. BPM must be within the genre's valid range per the guide
|
||||
5. Duration: 180-300 seconds
|
||||
6. image_prompt must include "makoto shinkai style" and "no text"
|
||||
7. youtube_title must include emoji + Korean + English
|
||||
8. playlist_category must be one of the 8 categories in the guide
|
||||
|
||||
Output format:
|
||||
{{
|
||||
"date": "{target_date}",
|
||||
"theme": "<한국어로 날짜/계절/분위기 한줄 요약>",
|
||||
"tracks": [
|
||||
{{
|
||||
"id": "track_001",
|
||||
"caption": "<english style/mood/instruments description>",
|
||||
"lyrics": "[Instrumental]",
|
||||
"bpm": <number>,
|
||||
"key_scale": "<key> <Major/Minor>",
|
||||
"time_signature": "4",
|
||||
"duration": <180-300>,
|
||||
"image_prompt": "makoto shinkai style, <scene description>, no text, no watermark",
|
||||
"youtube_title": "<emoji> <한국어 제목> | <English Title> BGM",
|
||||
"youtube_tags": ["<tag1>", "<tag2>", ...],
|
||||
"playlist_category": "<one of 8 categories>"
|
||||
}},
|
||||
{{
|
||||
"id": "track_002",
|
||||
...
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
def run_claude_code_cli(prompt: str, target_date: str) -> dict:
|
||||
"""Run Claude Code CLI and parse JSON output."""
|
||||
logger.info("Running Claude Code CLI for precache generation...")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["claude", "-p", prompt, "--output-format", "text"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
cwd=str(PROJECT_DIR)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Claude CLI failed (rc={result.returncode}): {result.stderr[:500]}")
|
||||
return None
|
||||
|
||||
output = result.stdout.strip()
|
||||
|
||||
# Try to extract JSON from output (handle potential wrapping)
|
||||
json_start = output.find("{")
|
||||
json_end = output.rfind("}") + 1
|
||||
if json_start == -1 or json_end == 0:
|
||||
logger.error(f"No JSON found in output: {output[:300]}")
|
||||
return None
|
||||
|
||||
json_str = output[json_start:json_end]
|
||||
data = json.loads(json_str)
|
||||
|
||||
# Validate structure
|
||||
if "tracks" not in data or len(data["tracks"]) != 2:
|
||||
logger.error(f"Invalid structure: expected 2 tracks, got {len(data.get('tracks', []))}")
|
||||
return None
|
||||
|
||||
# Validate each track
|
||||
required_fields = ["id", "caption", "lyrics", "bpm", "key_scale",
|
||||
"time_signature", "duration", "image_prompt",
|
||||
"youtube_title", "youtube_tags", "playlist_category"]
|
||||
valid_categories = ["신나는 음악", "감성 음악", "잔잔한 음악", "집중 음악",
|
||||
"수면 음악", "카페 음악", "운동 음악", "드라이브 음악"]
|
||||
|
||||
for i, track in enumerate(data["tracks"]):
|
||||
for field in required_fields:
|
||||
if field not in track:
|
||||
logger.error(f"Track {i} missing field: {field}")
|
||||
return None
|
||||
if track["playlist_category"] not in valid_categories:
|
||||
logger.warning(f"Track {i} invalid category '{track['playlist_category']}', fixing...")
|
||||
track["playlist_category"] = "감성 음악"
|
||||
if not (180 <= track["duration"] <= 300):
|
||||
logger.warning(f"Track {i} duration {track['duration']} out of range, clamping")
|
||||
track["duration"] = max(180, min(300, track["duration"]))
|
||||
|
||||
return data
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Claude CLI timed out (120s)")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"JSON parse error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_precache(data: dict, target_date: str) -> Path:
|
||||
"""Save precache JSON to file."""
|
||||
PRECACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
output_path = PRECACHE_DIR / f"{target_date}.json"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Precache saved: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
target_date = get_target_date(sys.argv[1:])
|
||||
logger.info(f"=== Daily Precache: {target_date} ===")
|
||||
|
||||
# Check if already exists
|
||||
output_path = PRECACHE_DIR / f"{target_date}.json"
|
||||
if output_path.exists():
|
||||
logger.info(f"Precache already exists: {output_path}")
|
||||
with open(output_path, "r") as f:
|
||||
data = json.load(f)
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
# Generate with Claude Code CLI (up to 3 retries)
|
||||
prompt = build_prompt(target_date)
|
||||
data = None
|
||||
for attempt in range(3):
|
||||
logger.info(f"Attempt {attempt + 1}/3...")
|
||||
data = run_claude_code_cli(prompt, target_date)
|
||||
if data:
|
||||
break
|
||||
logger.warning(f"Attempt {attempt + 1} failed, retrying...")
|
||||
|
||||
if not data:
|
||||
logger.error("All attempts failed. Precache generation failed.")
|
||||
return 1
|
||||
|
||||
# Save
|
||||
save_precache(data, target_date)
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user