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:
javamon
2026-05-25 19:24:50 +09:00
parent 37d13be48d
commit c3f8d6b288
8 changed files with 1688 additions and 22 deletions

303
PRECACHE_GUIDE_MUSIC.md Executable file
View File

@@ -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*

View File

@@ -11,7 +11,7 @@ COMFYUI_URL = "http://localhost:8189"
COMFYUI_DIR = "/home/javamon/ComfyUI" COMFYUI_DIR = "/home/javamon/ComfyUI"
# YouTube # 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 YOUTUBE_CATEGORY_ID = "10" # Music
# Paths # Paths

223
daily_precache.py Executable file
View 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())

686
daily_scheduler.py Executable file
View File

@@ -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())

144
docs/PIPELINE_V2.md Normal file
View File

@@ -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*

View File

@@ -24,22 +24,40 @@ def _start_comfyui():
except Exception: except Exception:
pass pass
# 기존 8188 (WAN) 종료 # GPU 프로세스 전부 종료 (ACE-Step, WAN, TTS)
for port in ["8188", "8001", "8000"]:
try: try:
requests.get("http://localhost:8188", timeout=2) result = subprocess.run(["fuser", f"{port}/tcp"], capture_output=True, text=True)
result = subprocess.run(["fuser", "8188/tcp"], capture_output=True, text=True)
for p in result.stdout.strip().split(): for p in result.stdout.strip().split():
if p.strip().isdigit(): if p.strip().isdigit():
os.kill(int(p.strip()), signal.SIGTERM) os.kill(int(p.strip()), signal.SIGKILL)
time.sleep(5)
except Exception: except Exception:
pass 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 시작 # FLUX ComfyUI 시작
print(" [FLUX] ComfyUI 시작 중...", flush=True) print(" [FLUX] ComfyUI 시작 중...", flush=True)
subprocess.Popen( subprocess.Popen(
[f"{COMFYUI_DIR}/venv/bin/python3", "main.py", [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, cwd=COMFYUI_DIR,
stdout=open("/tmp/comfyui_flux_music.log", "a"), stdout=open("/tmp/comfyui_flux_music.log", "a"),
stderr=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}}, "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}},
"5": {"class_type": "EmptyLatentImage", "inputs": { "5": {"class_type": "EmptyLatentImage", "inputs": {
"width": 1024, "height": 576, "batch_size": 1, "width": 1344, "height": 768, "batch_size": 1,
}}, }},
"6": {"class_type": "KSampler", "inputs": { "6": {"class_type": "KSampler", "inputs": {
"model": ["2", 0], "positive": ["4", 0], "negative": ["4", 0], "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") pid = resp.json().get("prompt_id")
# 완료 대기 (최대 15분) # 완료 대기 (최대 20분, 프로세스 생존 확인)
while (time.time() - t0) < 900: _fail_count = 0
h = requests.get(f"{COMFYUI_URL}/history/{pid}", timeout=5).json() 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: if pid in h:
status = h[pid].get("status", {}).get("status_str", "") status = h[pid].get("status", {}).get("status_str", "")
if status == "success": if status == "success":
@@ -154,7 +185,7 @@ def generate_image(prompt, output_path):
return False return False
time.sleep(5) time.sleep(5)
print(f" [FLUX] 타임아웃 (15분)", flush=True) print(f" [FLUX] 타임아웃 (20분)", flush=True)
return False return False
except Exception as e: except Exception as e:

247
upload_scheduled.py Executable file
View File

@@ -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()

View File

@@ -173,13 +173,45 @@ def upload_video(video_path, title, thumbnail_path=None, extra_tags=None, privac
return video_id return video_id
# 뮤직큐우 재생목록 (기존) # 재생목록 캐시
PLAYLIST_ID = "PLr8dPYZT-hCUjL-OgPxJdF81Dvn_g8Vbg" _PLAYLIST_CACHE = {}
def create_or_get_playlist(title="뮤직큐우"): def create_or_get_playlist(title="반려동물 음악"):
"""재생목록 ID 반환 (기존 '뮤직큐우' 사용)""" """재생목록 ID 반환 (없으면 자동 생성)"""
return PLAYLIST_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): def add_to_playlist(video_id, playlist_id):