- 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>
224 lines
7.1 KiB
Python
Executable File
224 lines
7.1 KiB
Python
Executable File
#!/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())
|