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