From c3f8d6b288b335a7ca4a2915555587c0b8a6b295 Mon Sep 17 00:00:00 2001 From: javamon Date: Mon, 25 May 2026 19:24:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20v2=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=E2=80=94=20=EB=A7=A4=EC=9D=BC=202?= =?UTF-8?q?=EA=B3=A1=20=EB=8B=A4=EC=96=91=ED=95=9C=20=EC=9E=A5=EB=A5=B4=20?= =?UTF-8?q?BGM=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1/=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- PRECACHE_GUIDE_MUSIC.md | 303 ++++++++++++++++++ config.py | 2 +- daily_precache.py | 223 +++++++++++++ daily_scheduler.py | 686 ++++++++++++++++++++++++++++++++++++++++ docs/PIPELINE_V2.md | 144 +++++++++ generate_image.py | 63 +++- upload_scheduled.py | 247 +++++++++++++++ upload_youtube.py | 42 ++- 8 files changed, 1688 insertions(+), 22 deletions(-) create mode 100755 PRECACHE_GUIDE_MUSIC.md create mode 100755 daily_precache.py create mode 100755 daily_scheduler.py create mode 100644 docs/PIPELINE_V2.md create mode 100755 upload_scheduled.py diff --git a/PRECACHE_GUIDE_MUSIC.md b/PRECACHE_GUIDE_MUSIC.md new file mode 100755 index 0000000..97ff785 --- /dev/null +++ b/PRECACHE_GUIDE_MUSIC.md @@ -0,0 +1,303 @@ +# PRECACHE_GUIDE_MUSIC.md + +> Claude Code CLI가 음악 프리캐시 생성 시 참조하는 가이드 + +--- + +## 목적 + +매일 2개의 BGM 트랙을 기획합니다. 각 트랙은 3~5분 길이의 저작권 프리 음악으로, YouTube "ANIMILY" 채널에 업로드됩니다. + +--- + +## 장르 목록 및 BPM 범위 + +| 장르 | BPM 범위 | 특성 | 대표 악기/사운드 | +|------|---------|------|----------------| +| Lofi | 70-90 | 따뜻한, 노스탤직, 편안한 | Rhodes, vinyl crackle, mellow drums | +| Jazz | 100-140 | 세련된, 우아한, 즉흥적 | Piano, saxophone, upright bass, brushed drums | +| Classical | 60-120 | 장엄한, 감성적, 깊은 | Piano, strings, orchestra | +| EDM | 125-150 | 에너지, 리듬감, 강렬한 | Synth, bass drop, electronic drums | +| Ambient | 60-80 | 공간적, 명상적, 몽환적 | Pad synths, reverb, field recordings | +| Acoustic | 80-110 | 자연스러운, 따뜻한, 친근한 | Guitar, ukulele, cajon, claps | +| Pop | 100-130 | 밝은, 캐치한, 대중적 | Synth pop, piano, upbeat drums | +| Rock | 110-140 | 강렬한, 에너지, 드라이브 | Electric guitar, drums, bass | +| R&B | 80-110 | 그루브, 감성, 부드러운 | Rhodes, 808 bass, smooth vocals | +| Funk | 100-120 | 그루브, 리듬, 펑키 | Slap bass, wah guitar, tight drums | +| Bossa Nova | 110-140 | 라틴, 여유, 감미로운 | Nylon guitar, light percussion | +| Reggae | 70-90 | 릴렉스, 따뜻한, 리듬 | Offbeat guitar, bass, organ | +| Cinematic | 60-100 | 웅장한, 감동, 서사적 | Full orchestra, percussion, choir | +| Blues | 70-100 | 소울풀, 감성, 깊은 | Blues guitar, harmonica, piano | +| Folk | 90-120 | 소박한, 어쿠스틱, 스토리 | Acoustic guitar, banjo, violin | +| Country | 100-130 | 활기찬, 따뜻한, 자연 | Acoustic guitar, fiddle, pedal steel | +| Gospel | 80-120 | 영혼, 희망, 감동 | Piano, choir, organ | +| Synthwave | 100-130 | 레트로, 네온, 80s | Analog synth, arpeggiator, drum machine | +| Disco | 110-130 | 펑키, 댄스, 화려한 | Bass guitar, strings, hi-hat | + +--- + +## 계절/날씨 반영 규칙 + +### 계절별 추천 장르 + +| 계절 | 추천 장르 | 분위기 키워드 | +|------|----------|-------------| +| 봄 (3-5월) | Acoustic, Bossa Nova, Folk, Jazz, Pop | 따뜻한, 새로운, 산뜻한, 벚꽃, 바람 | +| 여름 (6-8월) | EDM, Reggae, Funk, Disco, Pop, Lofi | 시원한, 활기찬, 여행, 바다, 비 | +| 가을 (9-11월) | Jazz, Classical, Blues, R&B, Ambient | 감성적, 차분한, 단풍, 커피, 독서 | +| 겨울 (12-2월) | Classical, Ambient, Gospel, Cinematic, Lofi | 포근한, 고요한, 눈, 벽난로, 따뜻한 | + +### 기념일/이벤트 반영 + +기념일 전후 1~3일에는 해당 분위기를 반영하세요. 재생목록도 기념일 전용으로 만들 수 있습니다. + +| 날짜 | 이벤트 | 추천 분위기 | 재생목록 예시 | +|------|--------|-----------|-------------| +| 1/1 | 새해 | 희망적, 밝은, 새로운 시작 | 새해 음악 | +| 2/14 | 발렌타인데이 | 로맨틱, R&B, Jazz, 보사노바 | 발렌타인 | +| 3/1 | 삼일절 | 웅장한, Cinematic, 경건한 | - | +| 3/14 | 화이트데이 | 달콤한, Pop, Acoustic | - | +| 4/5 | 식목일 | 자연, 평화, Ambient, Folk | - | +| 5/1 | 근로자의 날 | 여유, 힐링, 잔잔한 | - | +| 5/5 | 어린이날 | 밝고 활기찬, Pop, Acoustic, 장난스러운 | 어린이날 음악 | +| 5/8 | 어버이날 | 감성적, Classical, Piano, 따뜻한 | - | +| 5/15 | 스승의 날 | 감사, 감성, Acoustic | - | +| 6/6 | 현충일 | 차분한, 경건한, Classical, Ambient | - | +| 6/25 | 6.25 전쟁일 | 경건한, Cinematic, Piano | - | +| 7/17 | 제헌절 | 경건한, 차분한 | - | +| 8/15 | 광복절 | 웅장한, Cinematic, 희망적 | - | +| 9월 중순 | 추석 (음력 8/15) | 한국적, 가을, Folk, Acoustic, 따뜻한 | 추석 음악 | +| 10/3 | 개천절 | 웅장한, Cinematic | - | +| 10/9 | 한글날 | 한국적, 감성, Acoustic | - | +| 10/31 | 할로윈 | 미스터리, Synthwave, 다크 앰비언트 | 할로윈 음악 | +| 11/11 | 빼빼로데이 | 달콤한, 경쾌한, Pop | - | +| 12/24 | 크리스마스 이브 | 따뜻한, 로맨틱, Jazz, Bell sounds | 크리스마스 음악 | +| 12/25 | 크리스마스 | 축제, 따뜻한, Jazz, Acoustic, Gospel | 크리스마스 음악 | +| 12/31 | 연말 | 회고적, 감성, Lofi, Jazz | 연말 음악 | + +**참고**: 위 목록에 없는 기념일이나 시즌 이벤트도 자유롭게 반영하세요. (예: 벚꽃 시즌, 장마철, 수능, 졸업식 등) + +### 요일별 참고 (필수는 아님) + +| 요일 | 분위기 경향 | +|------|-----------| +| 월요일 | 활기찬 시작, 긍정적 에너지 | +| 화요일 | 집중, 차분한 | +| 수요일 | 중간, 균형잡힌 | +| 목요일 | 기대감, 약간 업템포 | +| 금요일 | 신나는, 파티, 업비트 | +| 토요일 | 여유, 휴식, 카페 | +| 일요일 | 평온, 휴식, 감성 | + +--- + +## YouTube 제목 형식 + +### 규칙 +1. 이모지로 시작 +2. **감성적이고 상황 기반** 한국어 제목 — 장르명 나열 금지, 듣는 사람의 감정/상황을 담아서 +3. `|` 구분자 +4. English subtitle + "BGM" +5. 50자 이내 권장 + +### 추천 이모지 +🎵 🎶 🎹 🎸 🎷 ☕ 🌙 ☀️ 🌊 🍂 ❄️ 🌸 💫 ✨ 🎧 🌧️ 🌃 🏠 💤 🚗 📚 🌅 🌻 + +### 제목 스타일 — 감성적, 상황 기반, 공감형 + +**좋은 예시** (이런 느낌으로): +- 🌧️ 비 오는 날 창가에서 듣는 피아노 | Rainy Day Piano BGM +- 😴 출근하기 싫은 월요일 아침 | Monday Morning Blues BGM +- ❄️ 눈 내리는 밤, 따뜻한 코코아 한 잔 | Snowy Night BGM +- ☀️ 너무 더운 날 시원한 카페에서 | Hot Summer Cafe BGM +- 🍂 가을 낙엽 밟으며 걷는 길 | Autumn Walk BGM +- 🌙 새벽 2시, 혼자만의 시간 | Late Night Alone BGM +- 🚗 퇴근길 야경 보며 듣는 음악 | Night Drive BGM +- ☕ 일요일 오후, 아무것도 안 하는 중 | Lazy Sunday BGM +- 🌸 봄바람에 마음이 설레는 날 | Spring Breeze BGM +- 📚 시험 전날 밤, 집중해야 할 때 | Study Focus BGM +- 🏠 집에서 혼자 요리하면서 듣는 | Cooking Time BGM +- 💤 잠이 안 올 때 듣는 잔잔한 피아노 | Sleepless Night BGM +- 🌅 제주도 해변에서 듣고 싶은 음악 | Jeju Beach BGM +- 🎄 크리스마스 이브에 듣는 따뜻한 재즈 | Christmas Eve Jazz BGM + +**나쁜 예시** (이렇게 하지 마세요): +- ❌ 재즈 피아노 트리오 BGM (장르 나열) +- ❌ 어쿠스틱 기타 연주곡 (설명적) +- ❌ Lofi Hip Hop Mix (영어 위주) +- ❌ 무료 브금 모음 (매력 없음) + +--- + +## 재생목록 카테고리 분류 + +**자유롭게 결정하세요.** 곡의 분위기와 용도에 맞는 재생목록명을 직접 만드세요. 단, **전체 재생목록 수는 최대 20개 이내**로 유지하세요. 기존 재생목록에 맞는 곡이면 새로 만들지 말고 기존 목록에 추가하세요. +같은 이름의 재생목록이 이미 있으면 거기에 추가되고, 없으면 자동 생성됩니다. + +### 재생목록명 작성 규칙 +- 한국어로 간결하게 (2~5글자) +- 청취 상황 또는 분위기 기반 +- 시즌/이벤트 재생목록도 자유롭게 생성 가능 + +### 참고 예시 (이것에 한정되지 않음) +| 분위기/상황 | 재생목록명 예시 | +|------------|---------------| +| 활기찬, 신나는 | 신나는 음악, 텐션 업, 파티 음악 | +| 감성적, 서정적 | 감성 음악, 새벽 감성, 비 오는 날 | +| 차분한, 평화로운 | 잔잔한 음악, 힐링 타임, 명상 음악 | +| 집중, 공부 | 집중 음악, 공부할 때, 딥 포커스 | +| 수면, 휴식 | 수면 음악, 꿀잠 BGM, 자장가 | +| 카페, 분위기 | 카페 음악, 재즈 라운지, 보사노바 | +| 운동, 에너지 | 운동 음악, 러닝 BGM, 헬스장 | +| 드라이브, 이동 | 드라이브 음악, 야간 드라이브, 로드트립 | +| 계절 | 봄 음악, 여름 바이브, 가을 감성, 겨울 음악 | +| 기념일 | 크리스마스 음악, 새해 음악, 발렌타인 | +| 시간대 | 아침 음악, 오후 티타임, 한밤의 재즈 | +| 장소 | 해변 음악, 숲속 힐링, 도서관 BGM | + +--- + +## Vocal vs Instrumental 결정 기준 + +### Instrumental 선택 (기본값, 90%) +- BGM 용도의 트랙 +- 집중/수면/카페 카테고리 +- caption에 vocal/singing 관련 없음 + +### Vocal 선택 (10%, 특별한 경우만) +- 기념일/이벤트 특별 트랙 +- Gospel, R&B, Pop 장르 중 감성 강조 +- lyrics 형식: `[verse]`, `[chorus]`, `[bridge]` 구조 + +### Vocal 가사 규칙 +- 영어 가사만 사용 (한국어 발음 불안정) +- 간단하고 반복적인 구조 +- 8줄 이내 verse, 4줄 이내 chorus +- 예시: +``` +[verse] +Walking through the morning light +Everything feels so right +The breeze whispers gently +A brand new day + +[chorus] +Here we go, here we go +Let the sunshine flow +Every moment bright +Living in the light +``` + +--- + +## Caption 작성 규칙 + +### DO (해야 할 것) +- 스타일/장르 키워드: "lo-fi hip hop", "smooth jazz", "ambient electronic" +- 악기: "warm Rhodes", "nylon guitar", "soft piano", "analog synth" +- 질감/효과: "vinyl texture", "reverb-soaked", "tape saturation" +- 분위기: "laid-back groove", "dreamy atmosphere", "uplifting energy" +- 5-8개 키워드를 쉼표로 구분 + +### DON'T (하지 말 것) +- BPM 숫자 언급 금지 (별도 필드로 전달) +- Key/Scale 언급 금지 (별도 필드로 전달) +- 박자 정보 금지 (별도 필드로 전달) +- 장르만 단독 기재 금지 (구체적 묘사 필요) + +### 좋은 예시 +``` +lo-fi hip hop, warm Rhodes, vinyl texture, mellow drums, laid-back groove, gentle bass +smooth jazz piano trio, walking bass, brushed drums, intimate club atmosphere, subtle swing +cinematic orchestral, sweeping strings, french horn melody, epic build-up, emotional resolution +``` + +### 나쁜 예시 (금지) +``` +jazz, 120 BPM, C major ← 메타데이터 혼입 +hip hop beat ← 너무 추상적 +relaxing music for study ← 설명문이지 caption 아님 +``` + +--- + +## image_prompt 작성 규칙 + +### 필수 포함 +- `makoto shinkai style` (첫 부분) +- 구체적 장면 묘사 (풍경, 시간대, 날씨) +- `no text, no watermark` (마지막) + +### 권장 요소 +- 시간대: golden hour, sunset, night sky, early morning +- 자연: ocean, forest, mountains, meadow, sky +- 날씨: clear sky, rain, snow, clouds +- 빛: warm light, cool moonlight, neon glow +- 구도: wide shot, cinematic composition + +### 장르별 이미지 톤 + +| 장르 | 이미지 분위기 | +|------|-------------| +| Lofi | 비 오는 창가, 책상 위 커피, 도시 야경 | +| Jazz | 따뜻한 카페, 밤 도시, 무드등 | +| Classical | 광활한 자연, 일출, 호수 | +| EDM | 네온 도시, 별빛, 오로라 | +| Ambient | 우주, 안개 숲, 깊은 바다 | +| Acoustic | 햇살 가득한 공원, 꽃밭, 해변 | +| Bossa Nova | 해변 카페, 야자수, 석양 | +| Synthwave | 80s 네온, 레트로 도시, 석양 도로 | +| Cinematic | 웅장한 산맥, 폭풍 바다, 성 | + +--- + +## Key/Scale 선택 가이드 + +### 안정적인 키 (추천) +- **Major**: C, G, D, F, Bb, Eb +- **Minor**: Am, Em, Dm, Gm, Cm + +### 분위기별 키 +| 분위기 | 추천 키 | +|--------|--------| +| 밝고 따뜻한 | C Major, G Major, D Major | +| 감성적, 슬픈 | Am, Em, Dm | +| 세련된 | Eb Major, Bb Major, Fm | +| 어두운, 미스터리 | Cm, Gm, F# Minor | +| 웅장한 | D Major, Bb Major, Eb Major | + +--- + +## 다양성 규칙 + +1. **2개 트랙은 반드시 다른 장르** — 같은 장르 중복 금지 +2. **같은 playlist_category 중복 가능** — 하지만 다르면 더 좋음 +3. **전날과 같은 장르 피하기** — 연속 반복 금지 +4. **계절에 맞지 않는 장르 주의** — 겨울에 Reggae, 여름에 Christmas 등 + +--- + +## Time Signature 선택 + +| 값 | 의미 | 사용 장르 | +|----|------|----------| +| "4" | 4/4 박자 (기본) | 대부분의 장르 | +| "3" | 3/4 박자 (왈츠) | Classical 왈츠, 일부 Folk | +| "6" | 6/8 박자 | 일부 Ballad, Celtic | + +> 대부분 "4"를 사용합니다. "3"은 왈츠/클래식 전용. + +--- + +## Duration 가이드 + +| 범위 | 용도 | +|------|------| +| 180s (3분) | 짧고 임팩트 있는 트랙, EDM/Pop | +| 210-240s (3.5-4분) | 표준, 대부분의 장르 | +| 270-300s (4.5-5분) | 여유로운 트랙, Ambient/Classical/Jazz | + +--- + +*이 가이드는 Claude Code CLI가 매일 자동 참조합니다.* +*마지막 업데이트: 2026-05-24* diff --git a/config.py b/config.py index dc09045..bed1f08 100755 --- a/config.py +++ b/config.py @@ -11,7 +11,7 @@ COMFYUI_URL = "http://localhost:8189" COMFYUI_DIR = "/home/javamon/ComfyUI" # YouTube -TOKEN_PATH = "/home/javamon/project/auto_shorts/token_conimals.pickle" +TOKEN_PATH = "/home/javamon/project/animily_music/token_animily_music.pickle" YOUTUBE_CATEGORY_ID = "10" # Music # Paths diff --git a/daily_precache.py b/daily_precache.py new file mode 100755 index 0000000..6b565e9 --- /dev/null +++ b/daily_precache.py @@ -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": "", + "lyrics": "[Instrumental]", + "bpm": , + "key_scale": " ", + "time_signature": "4", + "duration": <180-300>, + "image_prompt": "makoto shinkai style, , no text, no watermark", + "youtube_title": " <한국어 제목> | BGM", + "youtube_tags": ["", "", ...], + "playlist_category": "" + }}, + {{ + "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()) diff --git a/daily_scheduler.py b/daily_scheduler.py new file mode 100755 index 0000000..98bcf63 --- /dev/null +++ b/daily_scheduler.py @@ -0,0 +1,686 @@ +#!/usr/bin/env python3 +""" +daily_scheduler.py — Animily Music v2 메인 오케스트레이터 + +매일 00:30 실행: +1. Precache (Claude Code CLI) → 2 tracks JSON +2. ACE-Step으로 음악 생성 +3. FLUX로 이미지 생성 +4. ffmpeg으로 영상 렌더 +5. Upload queue에 저장 +6. GPU 프로세스 정리 (03:00 auto_shorts 전에 완료 필수) + +Cron: + 30 0 * * * cd /home/javamon/project/animily_music && venv/bin/python3 daily_scheduler.py +""" + +import os +import sys +import json +import time +import uuid +import shutil +import signal +import logging +import subprocess +import requests +from datetime import datetime, date, timedelta +from pathlib import Path + +# Project paths +PROJECT_DIR = Path("/home/javamon/project/animily_music") +OUTPUT_DIR = PROJECT_DIR / "outputs" +QUEUE_DIR = PROJECT_DIR / "upload_queue" +UPLOAD_QUEUE_PATH = PROJECT_DIR / "upload_queue.json" +PRECACHE_DIR = PROJECT_DIR / "precache" +LOG_DIR = PROJECT_DIR / "logs" + +# API endpoints +ACESTEP_URL = "http://localhost:8001" +COMFYUI_URL = "http://localhost:8189" + +# Timeouts +ACESTEP_TIMEOUT = 600 # 10 min per track +COMFYUI_TIMEOUT = 900 # 15 min per image (FLUX needs ~10min) +PIPELINE_DEADLINE = 8100 # 2h15m total (must finish before 03:00) + +# 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 / "daily.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +# ============================================================ +# GPU / Service Management +# ============================================================ + +def check_health(url: str, endpoint: str = "/health") -> bool: + """Check if a service is healthy.""" + try: + r = requests.get(f"{url}{endpoint}", timeout=5) + return r.status_code == 200 + except: + return False + + +def start_acestep(): + """Start ACE-Step server if not running.""" + if check_health(ACESTEP_URL, "/health"): + logger.info("ACE-Step already running") + return True + + logger.info("Starting ACE-Step server...") + subprocess.Popen( + ["bash", "-c", "cd /home/javamon/ACE-Step-1.5 && source venv/bin/activate && python -m uvicorn acestep.api_server:app --host 0.0.0.0 --port 8001 --workers 1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # Wait for startup + for i in range(60): + time.sleep(5) + if check_health(ACESTEP_URL, "/health"): + logger.info(f"ACE-Step ready after {(i+1)*5}s") + return True + + logger.error("ACE-Step failed to start within 5 minutes") + return False + + +def start_comfyui(): + """Start ComfyUI for FLUX image generation.""" + if check_health(COMFYUI_URL, "/"): + logger.info("ComfyUI already running") + return True + + # Stop TTS service to free GPU memory + logger.info("Stopping Qwen-TTS to free memory for FLUX...") + subprocess.run(["sudo", "systemctl", "stop", "qwen-tts.service"], capture_output=True) + subprocess.run(["sudo", "systemctl", "disable", "qwen-tts.service"], capture_output=True) + time.sleep(3) + + logger.info("Starting ComfyUI with --disable-mmap...") + subprocess.Popen( + ["bash", "-c", + "cd /home/javamon/ComfyUI && ./venv/bin/python main.py " + "--listen 0.0.0.0 --port 8189 --disable-mmap &"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + for i in range(60): + time.sleep(5) + if check_health(COMFYUI_URL, "/"): + logger.info(f"ComfyUI ready after {(i+1)*5}s") + return True + + logger.error("ComfyUI failed to start within 5 minutes") + return False + + +def kill_gpu_processes(): + """Kill all GPU processes (ACE-Step, ComfyUI) and clean up.""" + logger.info("=== GPU Cleanup Started ===") + + # Kill ACE-Step + subprocess.run(["fuser", "-k", "8001/tcp"], capture_output=True) + subprocess.run(["pkill", "-9", "-f", "acestep.api_server"], capture_output=True) + + # Kill ComfyUI + subprocess.run(["pkill", "-f", "ComfyUI"], capture_output=True) + subprocess.run(["pkill", "-f", "comfyui"], capture_output=True) + + time.sleep(3) + + # Force kill if still alive + subprocess.run(["pkill", "-9", "-f", "acestep.api_server"], capture_output=True) + subprocess.run(["pkill", "-9", "-f", "ComfyUI"], capture_output=True) + + # Re-enable TTS service + logger.info("Re-enabling Qwen-TTS service...") + subprocess.run(["sudo", "systemctl", "enable", "qwen-tts.service"], capture_output=True) + subprocess.run(["sudo", "systemctl", "start", "qwen-tts.service"], capture_output=True) + + # Drop caches + subprocess.run(["sudo", "bash", "-c", "echo 3 > /proc/sys/vm/drop_caches"], capture_output=True) + + logger.info("=== GPU Cleanup Complete ===") + + +# ============================================================ +# Music Generation (ACE-Step) +# ============================================================ + +def generate_music(track: dict, output_path: Path) -> bool: + """Generate music using ACE-Step API.""" + logger.info(f"Generating music: {track['id']} ({track['duration']}s, {track['bpm']}bpm)") + + payload = { + "caption": track["caption"], + "lyrics": track["lyrics"], + "bpm": track["bpm"], + "key_scale": track["key_scale"], + "time_signature": track["time_signature"], + "audio_duration": track["duration"], + "batch_size": 1, + "thinking": True, + "inference_steps": 8, + "guidance_scale": 7.0, + "audio_format": "wav" + } + + try: + # Submit task + r = requests.post(f"{ACESTEP_URL}/release_task", json=payload, timeout=30) + if r.status_code != 200: + logger.error(f"ACE-Step submit failed: {r.status_code} {r.text[:200]}") + return False + + result = r.json() + # ACE-Step returns {"data": {"task_id": "...", "status": "queued"}, "code": 200} + task_id = result.get("data", {}).get("task_id") + if not task_id: + logger.error(f"No task_id in response: {result}") + return False + + logger.info(f"Task submitted: {task_id}") + + # Poll for completion + start_time = time.time() + while time.time() - start_time < ACESTEP_TIMEOUT: + time.sleep(10) + try: + poll_r = requests.post( + f"{ACESTEP_URL}/query_result", + json={"task_id_list": [task_id]}, + timeout=15 + ) + if poll_r.status_code != 200: + continue + + items = poll_r.json().get("data", []) + if not items: + continue + + item = items[0] if isinstance(items, list) else items + status = item.get("status") + + if status == 1: # completed + import json as _json + result_str = item.get("result", "[]") + try: + result_list = _json.loads(result_str) if isinstance(result_str, str) else result_str + except: + result_list = [] + if result_list and isinstance(result_list, list): + file_url = result_list[0].get("file", "") + if file_url: + return download_audio(file_url, output_path) + logger.error(f"Completed but no audio: {item}") + return False + elif status == 2: # failed + logger.error(f"Task failed: {item}") + return False + # status == 0: still running + + except requests.RequestException: + pass + + elapsed = int(time.time() - start_time) + if elapsed % 60 == 0: + logger.info(f" Waiting... ({elapsed}s elapsed)") + + logger.error(f"ACE-Step timeout after {ACESTEP_TIMEOUT}s") + return False + + except Exception as e: + logger.error(f"Music generation error: {e}") + return False + + +def download_audio(file_url: str, output_path: Path) -> bool: + """Download audio from ACE-Step /v1/audio endpoint.""" + import urllib.parse + if file_url.startswith("/"): + download_url = f"{ACESTEP_URL}{file_url}" + elif file_url.startswith("http"): + download_url = file_url + else: + download_url = f"{ACESTEP_URL}/v1/audio?path={urllib.parse.quote(file_url)}" + + try: + r = requests.get(download_url, timeout=120) + r.raise_for_status() + with open(output_path, "wb") as f: + f.write(r.content) + size_kb = os.path.getsize(output_path) // 1024 + if size_kb < 10: + logger.error(f"Downloaded file too small: {size_kb}KB") + return False + logger.info(f" Audio downloaded: {size_kb}KB") + return True + except Exception as e: + logger.error(f"Download failed: {e}") + return False + + + +def add_to_upload_queue(track_data: dict, video_path: str, thumbnail_path: str, upload_hour: int): + """auto_shorts 방식 upload_queue.json에 추가""" + queue = [] + if UPLOAD_QUEUE_PATH.exists(): + try: + queue = json.loads(UPLOAD_QUEUE_PATH.read_text(encoding="utf-8")) + except: + queue = [] + + entry = { + "schedule_key": track_data["id"], + "upload_hour": upload_hour, + "channel": "animily_music", + "video_path": video_path, + "thumbnail_path": thumbnail_path, + "title": track_data["youtube_title"], + "youtube_tags": track_data.get("youtube_tags", []), + "playlist_category": track_data.get("playlist_category", ""), + "created_date": datetime.now().strftime("%Y-%m-%d"), + "uploaded": False, + } + queue.append(entry) + UPLOAD_QUEUE_PATH.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info(f"Queued for upload at {upload_hour}:00: {track_data['youtube_title']}") + + +# FLUX.2 워크플로우 (1344x768, 16:9) +FLUX_WORKFLOW = { + "1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "flux2-dev-Q8_0.gguf"}}, + "2": {"class_type": "LoraLoader", "inputs": { + "model": ["1", 0], "clip": ["3", 0], + "lora_name": "flux2_turbo_comfy.safetensors", + "strength_model": 1.0, "strength_clip": 0.0, + }}, + "3": {"class_type": "CLIPLoader", "inputs": { + "clip_name": "mistral_3_small_flux2_bf16.safetensors", "type": "flux2", + }}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": ""}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": ""}}, + "5": {"class_type": "EmptyLatentImage", "inputs": { + "width": 1344, "height": 768, "batch_size": 1, + }}, + "4": {"class_type": "KSampler", "inputs": { + "model": ["2", 0], "positive": ["6", 0], "negative": ["7", 0], + "latent_image": ["5", 0], + "seed": 0, "steps": 8, "cfg": 1.0, + "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, + }}, + "8": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-ae.safetensors"}}, + "10": {"class_type": "VAEDecodeTiled", "inputs": { + "vae": ["8", 0], "samples": ["4", 0], + "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8, + }}, + "9": {"class_type": "SaveImage", "inputs": { + "images": ["10", 0], "filename_prefix": "flux_music_gen", + }}, +} + +def generate_image(prompt: str, output_path: Path) -> bool: + """Generate image using FLUX via ComfyUI.""" + logger.info(f"Generating image: {prompt[:80]}...") + + workflow = json.loads(json.dumps(FLUX_WORKFLOW)) + workflow["4"]["inputs"]["seed"] = int.from_bytes(os.urandom(4), "big") + workflow["6"]["inputs"]["text"] = prompt + workflow["7"]["inputs"]["text"] = "text, watermark, logo, signature, words, letters, blurry, low quality" + + try: + # Queue prompt + r = requests.post( + f"{COMFYUI_URL}/prompt", + json={"prompt": workflow}, + timeout=30 + ) + if r.status_code != 200: + logger.error(f"ComfyUI queue failed: {r.status_code} {r.text[:200]}") + return False + + prompt_id = r.json().get("prompt_id") + if not prompt_id: + logger.error("No prompt_id returned") + return False + + logger.info(f"ComfyUI prompt queued: {prompt_id}") + + # Poll for completion + start_time = time.time() + while time.time() - start_time < COMFYUI_TIMEOUT: + time.sleep(5) + try: + hist_r = requests.get(f"{COMFYUI_URL}/history/{prompt_id}", timeout=10) + if hist_r.status_code == 200: + hist = hist_r.json() + if prompt_id in hist: + status = hist[prompt_id].get("status", {}).get("status_str", "") + if status == "success": + # Find output image + import glob as _g + pngs = sorted(_g.glob("/home/javamon/ComfyUI/output/flux_music_gen_*.png")) + if pngs: + import shutil + shutil.copy2(pngs[-1], output_path) + for p in pngs: + os.remove(p) + elapsed = int(time.time() - start_time) + logger.info(f" Image generated in {elapsed}s") + return True + # Try outputs dict as fallback + outputs = hist[prompt_id].get("outputs", {}) + if "9" in outputs and outputs["9"].get("images"): + img_info = outputs["9"]["images"][0] + return download_comfyui_image(img_info, output_path) + elif status == "error": + logger.error("ComfyUI generation error") + return False + except: + continue + + logger.error(f"ComfyUI timeout after {COMFYUI_TIMEOUT}s") + return False + + except Exception as e: + logger.error(f"Image generation error: {e}") + return False + + +def download_comfyui_image(img_info: dict, output_path: Path) -> bool: + """Download image from ComfyUI.""" + try: + filename = img_info["filename"] + subfolder = img_info.get("subfolder", "") + img_type = img_info.get("type", "output") + + r = requests.get( + f"{COMFYUI_URL}/view", + params={"filename": filename, "subfolder": subfolder, "type": img_type}, + timeout=30 + ) + if r.status_code != 200: + logger.error(f"Image download failed: {r.status_code}") + return False + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "wb") as f: + f.write(r.content) + + logger.info(f"Image saved: {output_path}") + return True + + except Exception as e: + logger.error(f"Image download error: {e}") + return False + + +# ============================================================ +# Video Rendering (ffmpeg) +# ============================================================ + +def render_video(audio_path: Path, image_path: Path, output_path: Path, duration: int) -> bool: + """Render video: image + Ken Burns + watermark + audio.""" + logger.info(f"Rendering video: {output_path.name} ({duration}s)") + + output_path.parent.mkdir(parents=True, exist_ok=True) + font_path = "/usr/share/fonts/truetype/ONE_Mobile_Bold.otf" + + # Ken Burns: slow zoom from 1.0 to 1.05 over duration + filter_complex = ( + f"[0:v]scale=2688:1536,zoompan=z='1+0.05*on/{duration*30}'" + f":x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'" + f":d={duration*30}:s=1344x768:fps=30[v];" + f"[v]drawtext=text='ANIMILY'" + f":fontfile={font_path}" + f":fontsize=28:fontcolor=white@0.4" + f":x=w-tw-60:y=30" + f":shadowcolor=black@0.3:shadowx=1:shadowy=1[vout]" + ) + + cmd = [ + "ffmpeg", "-y", + "-loop", "1", "-i", str(image_path), + "-i", str(audio_path), + "-filter_complex", filter_complex, + "-map", "[vout]", "-map", "1:a", + "-c:v", "libx264", "-preset", "medium", "-crf", "23", + "-c:a", "aac", "-b:a", "192k", + "-t", str(duration), + "-pix_fmt", "yuv420p", + "-movflags", "+faststart", + str(output_path) + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode != 0: + logger.error(f"ffmpeg failed: {result.stderr[-500:]}") + return False + + size_mb = output_path.stat().st_size / (1024 * 1024) + logger.info(f"Video rendered: {output_path} ({size_mb:.1f}MB)") + return True + + except subprocess.TimeoutExpired: + logger.error("ffmpeg timeout (5 min)") + return False + except Exception as e: + logger.error(f"Render error: {e}") + return False + + +# ============================================================ +# Upload Queue +# ============================================================ + +def save_to_queue(track: dict, video_path: Path, image_path: Path, upload_time: str): + """Save track to upload queue.""" + QUEUE_DIR.mkdir(parents=True, exist_ok=True) + + queue_item = { + "id": track["id"], + "video_path": str(video_path), + "thumbnail_path": str(image_path), + "youtube_title": track["youtube_title"], + "youtube_tags": track["youtube_tags"], + "playlist_category": track["playlist_category"], + "scheduled_time": upload_time, + "status": "pending", + "created_at": datetime.now().isoformat() + } + + queue_file = QUEUE_DIR / f"{track['id']}_{datetime.now().strftime('%Y%m%d')}.json" + with open(queue_file, "w", encoding="utf-8") as f: + json.dump(queue_item, f, ensure_ascii=False, indent=2) + + logger.info(f"Queued for upload at {upload_time}: {queue_file.name}") + + +# ============================================================ +# Main Pipeline +# ============================================================ + +def run_precache(target_date: str) -> dict: + """Run daily_precache.py and return parsed data.""" + logger.info("Running precache...") + + result = subprocess.run( + [str(PROJECT_DIR / "venv/bin/python3"), str(PROJECT_DIR / "daily_precache.py"), + "--date", target_date], + capture_output=True, + text=True, + timeout=180, + cwd=str(PROJECT_DIR) + ) + + if result.returncode != 0: + logger.error(f"Precache failed: {result.stderr[:500]}") + return None + + # Load from file + precache_file = PRECACHE_DIR / f"{target_date}.json" + if not precache_file.exists(): + logger.error(f"Precache file not found: {precache_file}") + return None + + with open(precache_file, "r", encoding="utf-8") as f: + return json.load(f) + + +def process_track(track: dict, work_dir: Path) -> bool: + """Process a single track: music → image → video → queue.""" + track_id = track["id"] + logger.info(f"--- Processing {track_id} ---") + + audio_path = work_dir / f"{track_id}.wav" + image_path = work_dir / f"{track_id}.png" + video_path = work_dir / f"{track_id}.mp4" + + # Step 1: Generate music + if not generate_music(track, audio_path): + logger.error(f"{track_id}: Music generation failed") + return False + + # Step 2: Generate image + if not generate_image(track["image_prompt"], image_path): + logger.error(f"{track_id}: Image generation failed") + return False + + # Step 3: Render video + if not render_video(audio_path, image_path, video_path, track["duration"]): + logger.error(f"{track_id}: Video render failed") + return False + + return True + + +def main(): + pipeline_start = time.time() + today = date.today() + target_date = (today + timedelta(days=1)).strftime("%Y-%m-%d") + + logger.info("=" * 60) + logger.info(f"=== Animily Music Daily Scheduler: {today} ===") + logger.info(f"=== Target publish date: {target_date} ===") + logger.info("=" * 60) + + success_count = 0 + + try: + # Step 1: Precache + precache_data = run_precache(target_date) + if not precache_data: + logger.error("Precache failed. Aborting.") + return 1 + + tracks = precache_data.get("tracks", []) + if not tracks: + logger.error("No tracks in precache. Aborting.") + return 1 + + logger.info(f"Theme: {precache_data.get('theme', 'N/A')}") + logger.info(f"Tracks to process: {len(tracks)}") + + # Step 2: Start ACE-Step + if not start_acestep(): + logger.error("Cannot start ACE-Step. Aborting.") + return 1 + + # Work directory + work_dir = OUTPUT_DIR / target_date + work_dir.mkdir(parents=True, exist_ok=True) + + # Step 3: Generate music for all tracks first (before switching to FLUX) + audio_paths = {} + for track in tracks: + audio_path = work_dir / f"{track['id']}.wav" + if generate_music(track, audio_path): + audio_paths[track["id"]] = audio_path + else: + logger.warning(f"Skipping {track['id']}: music generation failed") + + # Check deadline + if time.time() - pipeline_start > PIPELINE_DEADLINE: + logger.error("Pipeline deadline exceeded!") + break + + # Kill ACE-Step before starting FLUX + logger.info("Killing ACE-Step for FLUX...") + subprocess.run(["fuser", "-k", "8001/tcp"], capture_output=True) + subprocess.run(["pkill", "-9", "-f", "acestep.api_server"], capture_output=True) + time.sleep(5) + + # Step 4: Start ComfyUI for FLUX + if audio_paths and not start_comfyui(): + logger.error("Cannot start ComfyUI. Skipping image generation.") + else: + # Generate images + for track in tracks: + if track["id"] not in audio_paths: + continue + + image_path = work_dir / f"{track['id']}.png" + if not generate_image(track["image_prompt"], image_path): + logger.warning(f"Skipping {track['id']}: image generation failed") + del audio_paths[track["id"]] + + if time.time() - pipeline_start > PIPELINE_DEADLINE: + logger.error("Pipeline deadline exceeded!") + break + + # Kill ComfyUI before rendering + logger.info("Killing ComfyUI for render phase...") + subprocess.run(["pkill", "-f", "ComfyUI"], capture_output=True) + time.sleep(3) + + # Step 5: Render videos + upload_times = ["07:00", "15:00"] + for i, track in enumerate(tracks): + if track["id"] not in audio_paths: + continue + + audio_path = work_dir / f"{track['id']}.wav" + image_path = work_dir / f"{track['id']}.png" + video_path = work_dir / f"{track['id']}.mp4" + + if not image_path.exists(): + continue + + if render_video(audio_path, image_path, video_path, track["duration"]): + # Save to queue + upload_hour = [7, 15][i] if i < 2 else 15 + add_to_upload_queue(track, str(video_path), str(image_path), upload_hour) + success_count += 1 + else: + logger.warning(f"Skipping {track['id']}: render failed") + + logger.info(f"Pipeline complete: {success_count}/{len(tracks)} tracks successful") + + except Exception as e: + logger.error(f"Pipeline error: {e}", exc_info=True) + + finally: + # CRITICAL: Always clean up GPU processes + kill_gpu_processes() + + elapsed = int(time.time() - pipeline_start) + logger.info(f"Total time: {elapsed // 60}m {elapsed % 60}s") + logger.info("=" * 60) + + return 0 if success_count > 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/PIPELINE_V2.md b/docs/PIPELINE_V2.md new file mode 100644 index 0000000..6ed1415 --- /dev/null +++ b/docs/PIPELINE_V2.md @@ -0,0 +1,144 @@ +# Animily Music v2 파이프라인 + +> 매일 2곡 다양한 장르 BGM 자동 생성 → @animily-music 유튜브 업로드 +> 작성: 2026-05-25 + +--- + +## 크론 스케줄 + +| 시간 | 스크립트 | 설명 | +|------|---------|------| +| **00:30** | `daily_scheduler.py` | 프리캐시 → 음악 생성 → 이미지 생성 → 영상 렌더 → 큐 저장 | +| **매시간** | `upload_scheduled.py` | upload_queue.json에서 현재 시간 건 업로드 (07:00, 15:00) | + +--- + +## 파이프라인 흐름 + +``` +1. Claude Code CLI → precache/{날짜}.json (2곡 기획) + - 날짜/계절/기념일/요일 기반 장르+테마 결정 + - PRECACHE_GUIDE_MUSIC.md 참조 + +2. ACE-Step (localhost:8001) → WAV 음악 생성 + - 3~5분, batch_size=1, thinking=True + - 완료 후 ACE-Step 프로세스 kill (GPU 해제) + +3. FLUX via ComfyUI (localhost:8189) → PNG 이미지 생성 + - 1344x768 (16:9), 신카이 마코토 스타일 + - TTS systemctl stop+disable 후 시작 + - 완료 후 ComfyUI kill + +4. ffmpeg → MP4 영상 렌더링 + - 이미지 + Ken Burns + 워터마크(ANIMILY) + 음악 + +5. upload_queue.json → 큐 저장 + - track_001: upload_hour=7 + - track_002: upload_hour=15 + +6. GPU Cleanup + - ACE-Step/ComfyUI/TTS 전부 kill + - drop_caches, TTS service enable+start + - auto_shorts 03:00 전 완료 보장 +``` + +--- + +## 파일 구조 + +``` +/home/javamon/project/animily_music/ +├── daily_precache.py # Claude Code CLI 프리캐시 생성 +├── daily_scheduler.py # 메인 오케스트레이터 +├── upload_scheduled.py # 큐 기반 업로드 (매시간 크론) +├── PRECACHE_GUIDE_MUSIC.md # Claude Code CLI 참조 가이드 +├── config.py # 설정 +├── generate_music.py # ACE-Step API (레거시, v1용) +├── generate_image.py # FLUX ComfyUI (레거시, v1용) +├── render_video.py # ffmpeg 렌더 (레거시, v1용) +├── scheduler.py # v1 스케줄러 (레거시) +├── upload_youtube.py # v1 업로드 (레거시) +├── token_animily_music.pickle # @animily-music YouTube OAuth 토큰 +├── precache/ # 프리캐시 JSON +├── upload_queue.json # 업로드 큐 +├── outputs/ # 임시 생성물 +├── logs/ # 로그 +└── prompts/ # 프롬프트 (레거시) +``` + +--- + +## YouTube 채널 + +| 항목 | 값 | +|------|-----| +| 채널명 | 애니밀리 뮤직 Animily Music | +| URL | https://www.youtube.com/@animily-music | +| ID | UCtT5K3-D9gAid7lT7XmrfvA | +| 토큰 | token_animily_music.pickle | + +--- + +## 재생목록 (자동 생성) + +신나는 음악, 감성 음악, 잔잔한 음악, 집중 음악, 수면 음악, 카페 음악, 운동 음악, 드라이브 음악 + +--- + +## 장르 (19개) + +로파이, 재즈, 클래식, EDM, 앰비언트, 어쿠스틱, 팝, 록, R&B, 펑크, 보사노바, 레게, 시네마틱, 블루스, 포크, 컨트리, 신스웨이브, 디스코, 가스펠 + +--- + +## GPU 리소스 관리 + +- ACE-Step: ~11GB (음악 생성 중만) +- FLUX: ~33GB (이미지 생성 중만) +- **동시 사용 금지** — 순차 실행 (ACE-Step → kill → FLUX → kill) +- TTS: `sudo systemctl stop/disable qwen-tts.service` (FLUX 전) +- 완료 후: `sudo systemctl enable/start qwen-tts.service` + +--- + +## 출처 표기 정책 + +사용자가 BGM 사용 시 설명란에 출처 필수: +``` +🎵 Music by 애니밀리 뮤직 (Animily Music) +🔗 https://www.youtube.com/@animily-music +``` + +--- + +## ACE-Step API + +| 항목 | 값 | +|------|-----| +| URL | http://localhost:8001 | +| 시작 | `cd /home/javamon/ACE-Step-1.5 && source venv/bin/activate && python -m uvicorn acestep.api_server:app --host 0.0.0.0 --port 8001 --workers 1` | +| Kill | `fuser -k 8001/tcp && pkill -9 -f acestep.api_server` | +| 제출 | POST /release_task | +| 폴링 | POST /query_result | +| 다운로드 | GET /v1/audio?path=... | +| 헬스 | GET /health | + +--- + +## ComfyUI GPU 안정성 (2026-05-25 패치) + +### 근본 원인 +1. TTS가 systemd Restart=always 서비스 → kill해도 10초 후 재시작 +2. MemAvailable ≠ GPU VRAM → nvidia-smi로 확인 필요 +3. 스케줄러 중복 실행 → GPU 프로세스 충돌 + +### 해결 +- TTS: `systemctl stop + disable` (Restart=always 우회) +- GPU VRAM: `nvidia-smi --query-compute-apps` 로 프로세스 확인 +- 메모리 해제: `drop_caches` + 180초 대기 + 40GB 미만 시 포기 +- ComfyUI 시작 후 OOM 크래시 감지 (pgrep 생존 확인) + +--- + +*마지막 업데이트: 2026-05-25* diff --git a/generate_image.py b/generate_image.py index 57d60ae..22bfb40 100755 --- a/generate_image.py +++ b/generate_image.py @@ -24,22 +24,40 @@ def _start_comfyui(): except Exception: pass - # 기존 8188 (WAN) 종료 - try: - requests.get("http://localhost:8188", timeout=2) - result = subprocess.run(["fuser", "8188/tcp"], capture_output=True, text=True) - for p in result.stdout.strip().split(): - if p.strip().isdigit(): - os.kill(int(p.strip()), signal.SIGTERM) - time.sleep(5) - except Exception: - pass + # GPU 프로세스 전부 종료 (ACE-Step, WAN, TTS) + for port in ["8188", "8001", "8000"]: + try: + result = subprocess.run(["fuser", f"{port}/tcp"], capture_output=True, text=True) + for p in result.stdout.strip().split(): + if p.strip().isdigit(): + os.kill(int(p.strip()), signal.SIGKILL) + except Exception: + pass + # TTS systemd 서비스 종료 + subprocess.run(["sudo", "systemctl", "stop", "qwen-tts.service"], capture_output=True, timeout=10) + subprocess.run(["sudo", "systemctl", "disable", "qwen-tts.service"], capture_output=True, timeout=5) + # drop_caches + GPU VRAM 해제 대기 + subprocess.run(["sudo", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"], capture_output=True, timeout=5) + time.sleep(5) + # GPU에서 비관련 프로세스만 남을 때까지 대기 (최대 60초) + for _w in range(30): + try: + _gpu = subprocess.run(["nvidia-smi", "--query-compute-apps=pid,process_name", "--format=csv,noheader"], + capture_output=True, text=True, timeout=5) + _procs = [l for l in _gpu.stdout.strip().split("\n") if l.strip()] + _blocking = [p for p in _procs if any(x in p for x in ["ComfyUI", "Qwen3", "acestep", "ACE"])] + if not _blocking: + print(f" [FLUX] GPU 메모리 해제 확인 ({(_w+1)*2}s)", flush=True) + break + except: + break + time.sleep(2) # FLUX ComfyUI 시작 print(" [FLUX] ComfyUI 시작 중...", flush=True) subprocess.Popen( [f"{COMFYUI_DIR}/venv/bin/python3", "main.py", - "--listen", "0.0.0.0", "--port", "8189", "--bf16-vae", "--lowvram"], + "--listen", "0.0.0.0", "--port", "8189", "--bf16-vae", "--disable-mmap"], cwd=COMFYUI_DIR, stdout=open("/tmp/comfyui_flux_music.log", "a"), stderr=open("/tmp/comfyui_flux_music.log", "a"), @@ -105,7 +123,7 @@ def generate_image(prompt, output_path): }}, "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}}, "5": {"class_type": "EmptyLatentImage", "inputs": { - "width": 1024, "height": 576, "batch_size": 1, + "width": 1344, "height": 768, "batch_size": 1, }}, "6": {"class_type": "KSampler", "inputs": { "model": ["2", 0], "positive": ["4", 0], "negative": ["4", 0], @@ -134,9 +152,22 @@ def generate_image(prompt, output_path): pid = resp.json().get("prompt_id") - # 완료 대기 (최대 15분) - while (time.time() - t0) < 900: - h = requests.get(f"{COMFYUI_URL}/history/{pid}", timeout=5).json() + # 완료 대기 (최대 20분, 프로세스 생존 확인) + _fail_count = 0 + while (time.time() - t0) < 1200: + try: + h = requests.get(f"{COMFYUI_URL}/history/{pid}", timeout=30).json() + _fail_count = 0 + except Exception: + _fail_count += 1 + _alive = subprocess.run(["pgrep", "-f", "main.py.*8189"], capture_output=True).returncode == 0 + if not _alive: + print(" [FLUX] 프로세스 죽음 (OOM 가능성)", flush=True) + return False + if _fail_count % 6 == 0: + print(f" [FLUX] HTTP 미응답 ({_fail_count}회) — 모델 로딩 대기...", flush=True) + time.sleep(10) + continue if pid in h: status = h[pid].get("status", {}).get("status_str", "") if status == "success": @@ -154,7 +185,7 @@ def generate_image(prompt, output_path): return False time.sleep(5) - print(f" [FLUX] 타임아웃 (15분)", flush=True) + print(f" [FLUX] 타임아웃 (20분)", flush=True) return False except Exception as e: diff --git a/upload_scheduled.py b/upload_scheduled.py new file mode 100755 index 0000000..9e7af4c --- /dev/null +++ b/upload_scheduled.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +animily_music 예약 업로드 — auto_shorts와 동일한 큐 방식 +upload_queue.json에서 현재 시간에 해당하는 항목만 업로드 + +crontab: 매시간 실행 +0 * * * * cd /home/javamon/project/animily_music && venv/bin/python3 upload_scheduled.py >> logs/upload.log 2>&1 +""" +import json +import logging +import os +import pickle +import sys +from datetime import datetime, timezone, timedelta +from pathlib import Path + +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +KST = timezone(timedelta(hours=9)) +PROJECT_DIR = Path("/home/javamon/project/animily_music") +QUEUE_PATH = PROJECT_DIR / "upload_queue.json" +TOKEN_PATH = PROJECT_DIR / "token_animily_music.pickle" + +DESCRIPTION_TEMPLATE = """{title} + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🎵 무료 BGM | 사용 시 출처 표기 부탁드립니다 +아래 내용을 영상 설명란에 복사해 주세요: + +🎵 Music by 애니밀리 뮤직 (Animily Music) +🔗 https://www.youtube.com/@animily-music + +━━━━━━━━━━━━━━━━━━━━━━━━ + +✅ 사용 가능 범위 +• YouTube, Instagram, TikTok, 블로그 등 개인 콘텐츠 무료 사용 +• 상업적 용도 (광고, 기업 영상 등) 무료 사용 +• 사용 시 설명란에 출처 (채널명 + 링크) 표기 필수 + +❌ 금지 사항 +• 음원 단독 재업로드 및 재배포 +• 자신의 음악인 것처럼 등록 또는 판매 +• 음원 파일 자체를 다운로드 링크로 공유 + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🐾 애니밀리 — 반려동물 행동교정 플랫폼 +📲 앱 다운로드: 앱스토어/구글플레이에서 '애니밀리' 검색 +🌐 https://animily.co.kr/ + +🤖 제작: AI 자동 생성 콘텐츠 (ACE-Step 1.5) + +#무료BGM #저작권프리 #배경음악 #BGM #copyrightfree #royaltyfree #backgroundmusic #animily +""" + +FIXED_COMMENT = """🎵 무료 BGM — 사용 시 출처만 남겨주세요! + +✅ 사용 방법: 아래 내용을 영상 설명란에 붙여넣기 +━━━━━━━━━━━━━━━━━━ +🎵 Music by 애니밀리 뮤직 (Animily Music) +🔗 https://www.youtube.com/@animily-music +━━━━━━━━━━━━━━━━━━ + +YouTube, Instagram, TikTok, 블로그 등 자유롭게 사용 가능합니다. +상업적 용도도 무료! 출처만 표기해 주세요 🙏 + +🎵 Free BGM — Just credit us! +Free for personal and commercial use. +No attribution needed ✅""" + +# 재생목록 캐시 +_playlist_cache = {} + + +def get_youtube_client(): + with open(TOKEN_PATH, "rb") as f: + creds = pickle.load(f) + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + with open(TOKEN_PATH, "wb") as f: + pickle.dump(creds, f) + logger.info("Token refreshed") + return build("youtube", "v3", credentials=creds) + + +def get_or_create_playlist(youtube, category: str) -> str: + if category in _playlist_cache: + return _playlist_cache[category] + + # 기존 재생목록 검색 + try: + pl = youtube.playlists().list(part="snippet", mine=True, maxResults=50).execute() + for item in pl.get("items", []): + if item["snippet"]["title"] == category: + _playlist_cache[category] = item["id"] + return item["id"] + except Exception as e: + logger.error(f"Playlist search error: {e}") + + # 없으면 생성 + try: + body = { + "snippet": { + "title": category, + "description": f"ANIMILY {category} - AI 생성 저작권 프리 BGM 모음", + }, + "status": {"privacyStatus": "public"}, + } + resp = youtube.playlists().insert(part="snippet,status", body=body).execute() + pl_id = resp["id"] + _playlist_cache[category] = pl_id + logger.info(f"Created playlist: {category} ({pl_id})") + return pl_id + except Exception as e: + logger.error(f"Playlist create error: {e}") + return None + + +def upload_one(youtube, item: dict) -> bool: + video_path = item["video_path"] + if not os.path.exists(video_path): + logger.error(f"Video not found: {video_path}") + return False + + title = item["title"][:100] + description = DESCRIPTION_TEMPLATE.format(title=title) + tags = item.get("youtube_tags", [])[:30] + + body = { + "snippet": { + "title": title, + "description": description, + "tags": tags, + "categoryId": "10", + "defaultLanguage": "ko", + }, + "status": { + "privacyStatus": "public", + "madeForKids": False, + "selfDeclaredMadeForKids": False, + }, + } + + logger.info(f"Uploading: {title}") + media = MediaFileUpload(video_path, mimetype="video/mp4", resumable=True, chunksize=50*1024*1024) + request = youtube.videos().insert(part="snippet,status", body=body, media_body=media) + + response = None + while response is None: + status, response = request.next_chunk() + + video_id = response.get("id") + logger.info(f"Upload complete: https://youtu.be/{video_id}") + + # 썸네일 + thumb = item.get("thumbnail_path", "") + if thumb and os.path.exists(thumb): + try: + youtube.thumbnails().set( + videoId=video_id, + media_body=MediaFileUpload(thumb, mimetype="image/png"), + ).execute() + logger.info("Thumbnail set") + except Exception as e: + logger.warning(f"Thumbnail failed: {e}") + + # 재생목록 + category = item.get("playlist_category", "") + if category: + pl_id = get_or_create_playlist(youtube, category) + if pl_id: + try: + youtube.playlistItems().insert( + part="snippet", + body={"snippet": {"playlistId": pl_id, "resourceId": {"kind": "youtube#video", "videoId": video_id}}}, + ).execute() + logger.info(f"Added to playlist: {category}") + except Exception as e: + logger.warning(f"Playlist add failed: {e}") + + # 고정 댓글 + try: + youtube.commentThreads().insert( + part="snippet", + body={"snippet": {"videoId": video_id, "topLevelComment": {"snippet": {"textOriginal": FIXED_COMMENT}}}}, + ).execute() + logger.info("Comment posted") + except Exception as e: + logger.warning(f"Comment failed: {e}") + + # 임시 파일 삭제 + try: + os.remove(video_path) + if thumb and os.path.exists(thumb): + os.remove(thumb) + logger.info(f"Temp files cleaned") + except: + pass + + item["uploaded"] = True + item["uploaded_at"] = datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S") + item["video_id"] = video_id + return True + + +def main(): + now = datetime.now(KST) + current_hour = now.hour + + if not QUEUE_PATH.exists(): + return + + queue = json.loads(QUEUE_PATH.read_text(encoding="utf-8")) + if not queue: + return + + # 현재 시간에 해당하는 미업로드 항목 + targets = [item for item in queue if not item.get("uploaded") and item.get("upload_hour") == current_hour] + + if not targets: + return + + logger.info(f"=== Upload Scheduled ({current_hour}:00) — {len(targets)}건 ===") + + youtube = get_youtube_client() + + for item in targets: + try: + upload_one(youtube, item) + except Exception as e: + logger.error(f"Upload error: {e}") + item["uploaded"] = False + item["error"] = str(e)[:200] + + # 큐 저장 + QUEUE_PATH.write_text(json.dumps(queue, ensure_ascii=False, indent=2), encoding="utf-8") + logger.info(f"Queue updated: {len([i for i in queue if i.get('uploaded')])} uploaded total") + + +if __name__ == "__main__": + main() diff --git a/upload_youtube.py b/upload_youtube.py index 7e02d44..bdeebe6 100755 --- a/upload_youtube.py +++ b/upload_youtube.py @@ -173,13 +173,45 @@ def upload_video(video_path, title, thumbnail_path=None, extra_tags=None, privac return video_id -# 뮤직큐우 재생목록 (기존) -PLAYLIST_ID = "PLr8dPYZT-hCUjL-OgPxJdF81Dvn_g8Vbg" +# 재생목록 캐시 +_PLAYLIST_CACHE = {} -def create_or_get_playlist(title="뮤직큐우"): - """재생목록 ID 반환 (기존 '뮤직큐우' 사용)""" - return PLAYLIST_ID +def create_or_get_playlist(title="반려동물 음악"): + """재생목록 ID 반환 (없으면 자동 생성)""" + if title in _PLAYLIST_CACHE: + return _PLAYLIST_CACHE[title] + + youtube = _get_youtube_client() + + # 기존 재생목록 검색 + try: + pl = youtube.playlists().list(part="snippet", mine=True, maxResults=50).execute() + for item in pl.get("items", []): + if item["snippet"]["title"] == title: + _PLAYLIST_CACHE[title] = item["id"] + print(f" [YT] 재생목록 찾음: {title} (id={item['id']})", flush=True) + return item["id"] + except Exception as e: + print(f" [YT] 재생목록 검색 실패: {e}", flush=True) + + # 없으면 생성 + try: + body = { + "snippet": { + "title": title, + "description": "AI 생성 반려동물 음악 | 저작권 프리 | Copyright Free Pet Music", + }, + "status": {"privacyStatus": "public"}, + } + resp = youtube.playlists().insert(part="snippet,status", body=body).execute() + pl_id = resp["id"] + _PLAYLIST_CACHE[title] = pl_id + print(f" [YT] 재생목록 생성: {title} (id={pl_id})", flush=True) + return pl_id + except Exception as e: + print(f" [YT] 재생목록 생성 실패: {e}", flush=True) + return None def add_to_playlist(video_id, playlist_id):