From 37d13be48d1f50b1047880e94c65459ad5dcf9d0 Mon Sep 17 00:00:00 2001 From: javamon Date: Tue, 21 Apr 2026 15:41:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EA=B5=AC=EC=84=B1:=20=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EB=8F=99=EB=AC=BC=20=EC=9D=8C=EC=95=85=20=EB=A1=B1=ED=8F=BC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ACE-Step 1.5 음악 생성 (과학적 근거 기반) - FLUX 이미지 생성 (신카이 마코토 스타일) - ffmpeg 영상 렌더링 (워터마크 포함) - YouTube Data API 롱폼 업로드 - 프롬프트 및 문서 포함 --- .gitignore | 7 + config.py | 36 +++++ docs/PET_MUSIC_SCIENCE.md | 230 +++++++++++++++++++++++++++++ docs/PROJECT_PLAN.md | 277 +++++++++++++++++++++++++++++++++++ generate_image.py | 192 ++++++++++++++++++++++++ generate_music.py | 249 +++++++++++++++++++++++++++++++ prompts/cat_music.json | 32 ++++ prompts/dog_music.json | 46 ++++++ prompts/image_prompts.json | 17 +++ render_video.py | 185 +++++++++++++++++++++++ requirements.txt | 6 + scheduler.py | 291 +++++++++++++++++++++++++++++++++++++ upload_youtube.py | 209 ++++++++++++++++++++++++++ 13 files changed, 1777 insertions(+) create mode 100644 .gitignore create mode 100755 config.py create mode 100755 docs/PET_MUSIC_SCIENCE.md create mode 100755 docs/PROJECT_PLAN.md create mode 100755 generate_image.py create mode 100755 generate_music.py create mode 100755 prompts/cat_music.json create mode 100755 prompts/dog_music.json create mode 100755 prompts/image_prompts.json create mode 100755 render_video.py create mode 100755 requirements.txt create mode 100755 scheduler.py create mode 100755 upload_youtube.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81d9b50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc +outputs/ +logs/ +*.pickle +*.log diff --git a/config.py b/config.py new file mode 100755 index 0000000..dc09045 --- /dev/null +++ b/config.py @@ -0,0 +1,36 @@ +"""Animily Music - 설정""" +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# ACE-Step API +ACESTEP_URL = "http://localhost:8001" + +# ComfyUI (FLUX) +COMFYUI_URL = "http://localhost:8189" +COMFYUI_DIR = "/home/javamon/ComfyUI" + +# YouTube +TOKEN_PATH = "/home/javamon/project/auto_shorts/token_conimals.pickle" +YOUTUBE_CATEGORY_ID = "10" # Music + +# Paths +OUTPUT_DIR = os.path.join(BASE_DIR, "outputs") +LOG_DIR = os.path.join(BASE_DIR, "logs") +PROMPTS_DIR = os.path.join(BASE_DIR, "prompts") + +# Watermark +WATERMARK_TEXT = "ANIMILY" +WATERMARK_FONT = "/usr/share/fonts/truetype/ONE_Mobile_Bold.otf" +WATERMARK_SIZE = 36 + +# Music segments +# GPU VRAM 제한(~11GB)으로 batch_size=1, 5분 세그먼트 사용 +SEGMENT_DURATION = 295 # seconds (just under 5 min, batch_size=1로 VRAM 절약) +SEGMENTS_FOR_1H = 12 # 12 * 295 = 3540s (~59min) +SEGMENTS_FOR_2H = 24 # 24 * 295 = 7080s (~118min) +CROSSFADE_SEC = 3 +BATCH_SIZE = 1 # GPU VRAM 절약 (기본 2 → 1) + +os.makedirs(OUTPUT_DIR, exist_ok=True) +os.makedirs(LOG_DIR, exist_ok=True) diff --git a/docs/PET_MUSIC_SCIENCE.md b/docs/PET_MUSIC_SCIENCE.md new file mode 100755 index 0000000..da386aa --- /dev/null +++ b/docs/PET_MUSIC_SCIENCE.md @@ -0,0 +1,230 @@ +# 반려동물 음악 생성 가이드 (ACE-Step 1.5) + +> AI 서버의 ACE-Step 1.5를 활용한 과학 기반 반려동물 음악 생성 + +--- + +## 개요 + +| 항목 | 값 | +|------|-----| +| AI 모델 | ACE-Step 1.5 | +| 서버 | 192.168.0.46 | +| 경로 | `/home/javamon/ACE-Step-1.5/` | +| API 포트 | 8001 | +| 체크포인트 | acestep-v15-turbo, acestep-5Hz-lm-1.7B, acestep-5Hz-lm-4B | + +--- + +## 과학적 근거 + +### 주요 연구 + +| 연구 | 저자 | 연도 | 핵심 발견 | +|------|------|------|----------| +| Influence of Auditory Stimulation on Dogs | Wells, Graham, Hepper (Queen's Univ Belfast) | 2002 | 클래식 → 수면 증가/짖기 감소, 헤비메탈 → 짖기/불안 증가 | +| Behavioral Effects of Auditory Stimulation | Kogan, Schoenfeld-Tacher, Simon (Colorado State) | 2012 | 클래식 → 수면 시간 증가, 4개월간 45분 세션 관찰 | +| Effect of Different Genres on Stress Levels | Bowman, Dowell, Evans + Scottish SPCA (Glasgow) | 2017 | **레게 + 소프트 록**에서 HRV 최고 (스트레스 최소) | +| Effects of Music Pitch and Tempo | Amaya, Satomura et al. | 2020 | 느린 템포 → 최대 이완 / 저음 피치 → 경계심 유발 | +| Effect of Music on Stress during Vet Visit | King, Flint, Hunt, Werzowa, Logan | 2022 | 품종 크기별 BPM 맞춤, 솔로 하프 효과 확인 | +| Through a Dog's Ear | Leeds, Spector, Wagner | 2008 | 50-60 BPM 솔로 피아노에서 70%+ 진정 반응 | +| Cats Prefer Species-Appropriate Music | Snowdon, Teie (Univ of Wisconsin) | 2015 | 고양이는 퍼링(25-50Hz) + 고음 성대(1000Hz+) 주파수에 반응 | + +### 강아지 - 긍정 반응 유발 요소 + +| 파라미터 | 최적값 | 근거 | +|---------|--------|------| +| 템포 | **50-80 BPM** | Through a Dog's Ear: 50-60 BPM에서 70%+ 진정 | +| 장르 | 레게, 소프트 록, 클래식 | Glasgow 2017: HRV 측정 | +| 악기 | 솔로 피아노, 하프, 어쿠스틱 기타 | 단순 편성일수록 효과적 | +| 다이나믹 | pp~mp (매우 좁은 범위) | King 2022 | +| 주파수 | 200Hz - 4kHz 중심 | 중음역 지배적 | +| 리듬 | 일정하고 예측 가능 | 싱코페이션 없음 | +| 화성 | I-IV-V, 협화음만 | 단순 진행 | + +### 강아지 - 품종 크기별 BPM (King 2022) + +| 크기 | 안정시 심박수 | 권장 BPM | +|------|-------------|---------| +| 대형견 | 95 ± 20 | 75-115 | +| 중형견 | 100 ± 20 | 80-120 | +| 소형견 | 120 ± 20 | 100-140 | + +### 고양이 - 긍정 반응 유발 요소 + +| 파라미터 | 최적값 | 근거 | +|---------|--------|------| +| 퍼링 주파수 | 25-50 Hz 드론 | Snowdon & Teie 2015 | +| 성대 모방 | 1000-1600 Hz 슬라이딩 | 고양이 울음소리 주파수 대역 | +| 템포 | 느림 (50-60 BPM) 또는 퍼링 리듬 | 호흡/심박 동조 | +| 악기 | 첼로 하모닉스, 바이올린 고음부 | 고양이 성대 주파수와 유사 | +| 기법 | 포르타멘토, 글리산도 | 고양이 발성 모방 | + +### 반드시 피해야 할 요소 + +| 요소 | 이유 | +|------|------| +| 헤비메탈/하드 록 | 짖기 증가, 몸 떨림 (Wells 2002, Kogan 2012) | +| 빠른 템포 (>120 BPM) | 각성도 증가 | +| 급격한 볼륨 변화 | 놀람 반응 유발 | +| 극저음 피치 | 공격 신호로 인식 (Morton 규칙) | +| 높은 피치 날카로운 소리 | 짖기/공포 반응 | +| 복잡한 편성 | 진정 효과 감소 | +| 타악기 (드럼, 심벌) | 예측 불가 충격음 | + +--- + +## ACE-Step 실행 방법 + +### 1. API 서버 시작 + +```bash +ssh javamon@192.168.0.46 +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 +``` + +### 2. 음악 생성 요청 + +```bash +curl -X POST http://192.168.0.46:8001/release_task \ + -H "Content-Type: application/json" \ + -d @request.json +``` + +### 3. 결과 조회 + +```bash +curl -X POST http://192.168.0.46:8001/query_result \ + -H "Content-Type: application/json" \ + -d '{"task_id": "<반환된 task_id>"}' +``` + +### 4. 오디오 다운로드 + +```bash +curl -o output.wav "http://192.168.0.46:8001/v1/audio?path=<파일경로>" +``` + +--- + +## 프롬프트 (강아지용) + +### A. 솔로 피아노 (최대 진정) + +```json +{ + "think": true, + "caption": "Extremely gentle solo piano piece. Warm, soft touch with narrow dynamic range (pp to mp). Simple stepwise melody with small intervals, repetitive soothing phrases. Consonant harmony only, simple I-IV-V progression. Mid-range frequencies dominant (200Hz-4kHz). No bass rumble, no high-frequency shimmer. Gradual fade-in, meditative and peaceful. Designed for canine relaxation based on psychoacoustic research.", + "lyrics": "[Instrumental]", + "bpm": 58, + "duration": 180, + "keyscale": "C major", + "language": "instrumental", + "timesignature": "4" +} +``` + +### B. 소프트 레게 (스트레스 감소) + +```json +{ + "think": true, + "caption": "Gentle soft reggae instrumental with acoustic guitar offbeat skank and warm piano chords. Light bass in 100-250Hz range, no drums or percussion. Simple repetitive melody, tropical and mellow. Very narrow dynamic range, smooth and flowing with no sudden changes. Mid-range focused, relaxed and soothing atmosphere.", + "lyrics": "[Instrumental]", + "bpm": 68, + "duration": 180, + "keyscale": "G major", + "language": "instrumental", + "timesignature": "4" +} +``` + +### C. 하프 클래식 (수의사 방문 전) + +```json +{ + "think": true, + "caption": "Solo harp playing simplified classical arrangements. Gentle arpeggios with warm resonance, extremely soft dynamics. Predictable harmonic rhythm, no tempo changes. Narrow frequency range focused on mid-register. Calming, ethereal, spa-like atmosphere. Slow and deliberate with natural ring-out between phrases.", + "lyrics": "[Instrumental]", + "bpm": 54, + "duration": 180, + "keyscale": "F major", + "language": "instrumental", + "timesignature": "4" +} +``` + +--- + +## 프롬프트 (고양이용) + +### A. 퍼링 기반 앰비언트 + +```json +{ + "think": true, + "caption": "Ultra-soft ambient piece with cello harmonics and gentle violin in higher register (1000-1600Hz range). Purring-like low drone undertone. Sliding portamento notes mimicking cat vocalizations. Extremely quiet, meditative, no rhythm section. Slow glissando strings, warm ethereal pad. No percussion, no sudden dynamic changes. Based on feline acoustic frequency preferences.", + "lyrics": "[Instrumental]", + "bpm": 60, + "duration": 180, + "keyscale": "A major", + "language": "instrumental", + "timesignature": "4" +} +``` + +### B. 고양이 자장가 (분리불안용) + +```json +{ + "think": true, + "caption": "Delicate lullaby for cats with soft violin playing in the 1000-1500Hz range, mimicking feline vocal frequency. Gentle sustained notes with natural vibrato and portamento slides. Background warm pad with subtle purring rhythm (25-30Hz pulse). Extremely minimal, quiet, and predictable. No low bass, no percussion, no sharp attacks. Soothing and hypnotic repetition.", + "lyrics": "[Instrumental]", + "bpm": 55, + "duration": 180, + "keyscale": "D major", + "language": "instrumental", + "timesignature": "4" +} +``` + +--- + +## 활용 시나리오 + +| 상황 | 추천 프롬프트 | 시간 | +|------|-------------|------| +| 강아지 수면/휴식 | 솔로 피아노 (A) | 3-5분 반복 | +| 강아지 분리불안 | 소프트 레게 (B) | 30분+ 연속 | +| 수의사 방문 전 | 하프 클래식 (C) | 이동 중 재생 | +| 고양이 휴식 | 퍼링 앰비언트 (A) | 3-5분 반복 | +| 고양이 분리불안 | 고양이 자장가 (B) | 30분+ 연속 | +| 유튜브 콘텐츠 | 강아지+고양이 혼합 | 1시간 영상용 | + +--- + +## 유튜브 콘텐츠 연계 (향후) + +- 애니밀리 채널에 "반려동물 음악" 카테고리 추가 가능 +- 1시간 루프 영상 (수면음악, ASMR 류) +- 과학적 근거 자막 오버레이로 차별화 +- ACE-Step으로 매일 다른 변주 자동 생성 + +--- + +## 참고문헌 + +1. Wells DL, Graham L, Hepper PG (2002). The influence of auditory stimulation on the behaviour of dogs housed in a rescue shelter. *Animal Welfare*, 11, 385-393. +2. Kogan LR, Schoenfeld-Tacher R, Simon AA (2012). Behavioral effects of auditory stimulation on kenneled dogs. *Journal of Veterinary Behavior*, 7, 268-275. +3. Bowman A, Dowell FJ, Evans NP, Scottish SPCA (2017). The effect of different genres of music on the stress levels of kennelled dogs. *Physiology & Behavior*, 171, 207-215. +4. Amaya V, Satomura H et al. (2020). Effects of music pitch and tempo on the behaviour of kennelled dogs. *Animals*, 11(1), 10. +5. King C, Flint H, Hunt ABG, Werzowa S, Logan DW (2022). Effect of music on stress parameters in dogs during a mock veterinary visit. *Frontiers in Veterinary Science*. +6. Leeds J, Spector L, Wagner S (2008). *Through a Dog's Ear: Using Sound to Improve the Health & Behavior of Your Canine Companion*. +7. Snowdon CT, Teie D (2015). Affective responses in tamarins elicited by species-specific music. *Biology Letters*, 6(1), 30-32. +8. Snowdon CT, Teie D, Savage M (2015). Cats prefer species-appropriate music. *Applied Animal Behaviour Science*, 166, 106-111. + +--- + +*작성: 2026-04-21* diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md new file mode 100755 index 0000000..20cb82c --- /dev/null +++ b/docs/PROJECT_PLAN.md @@ -0,0 +1,277 @@ +# Animily Music 프로젝트 계획 + +> 반려동물 음악 유튜브 롱폼 자동 생성 파이프라인 + +--- + +## 개요 + +| 항목 | 값 | +|------|-----| +| 서버 | 192.168.0.46 (AI 서버) | +| 프로젝트 경로 | `/home/javamon/project/animily_music/` | +| 음악 생성 | ACE-Step 1.5 (localhost:8001) | +| 이미지 생성 | FLUX via ComfyUI (localhost:8188) | +| 영상 합성 | ffmpeg | +| 업로드 | YouTube Data API (롱폼) | +| 크론탭 | 매주 토요일 02:00 (주석 처리 상태) | +| 기존 코드 | 절대 수정 금지 (완전 독립 프로젝트) | + +--- + +## 음악 생성 전략 + +### ACE-Step 제한 + +- 1회 최대 생성: **10분 (600초)** +- 세그먼트 방식으로 긴 곡 구성 + +### 길이별 구성 + +| 영상 길이 | 음악 생성 | 방식 | +|----------|----------|------| +| **2시간 (기본)** | 10분 × 12세그먼트 = ~119분 | 크로스페이드 이어붙이기 | +| 6시간 | 2시간 음악 × 3회 반복 | ffmpeg loop | +| 12시간 | 2시간 음악 × 6회 반복 | ffmpeg loop | + +### 세그먼트 이어붙이기 규칙 + +- 12개 세그먼트 각각 약 10분 (595초) 생성 +- 세그먼트 간 5초 크로스페이드 → 약 119분 완성 +- 동일 key/BPM 유지, caption만 미세 변주 (단조로움 방지) +- 마지막 세그먼트는 자연스러운 페이드아웃 + +--- + +## 영상 구성 + +### 화면 + +| 요소 | 위치 | 상세 | +|------|------|------| +| 배경 이미지 | 전체 화면 | FLUX 생성 (1920×1080, 16:9) | +| Ken Burns 효과 | 전체 | 미세한 줌인/패닝 (정지 느낌 방지) | +| **워터마크** | **우측 상단** | 영상 처음부터 끝까지 상시 표시 | + +### 워터마크 상세 + +| 항목 | 값 | +|------|-----| +| 텍스트 | **ANIMILY** | +| 위치 | 우측 상단 (x=w-tw-80, y=40) | +| 폰트 | ONE Mobile OTF Bold (`/usr/share/fonts/truetype/ONE_Mobile_Bold.otf`) | +| 폰트 크기 | 36 (롱폼 16:9 기준) | +| 색상 | white @ 40% 투명도 | +| 그림자 | black @ 30%, offset (1,1) | +| 표시 시간 | **영상 전체 (처음부터 끝까지 상시)** | + +#### ffmpeg 워터마크 필터 + +``` +drawtext=text='ANIMILY':fontfile=/usr/share/fonts/truetype/ONE_Mobile_Bold.otf:fontsize=36:fontcolor=white@0.4:x=w-tw-80:y=40:shadowcolor=black@0.3:shadowx=1:shadowy=1 +``` + +> 참고: 기존 쇼츠(1080x1920 세로)는 fontsize=30, x=w-tw-25, y=25 +> 롱폼(1920x1080 가로)은 비율에 맞게 조정 + +### 이미지 스타일 + +- **화풍**: 신카이 마코토 스타일 +- **주제**: 반려동물 + 자연 풍경 (평화로운 분위기) +- **해상도**: 1920×1080 (16:9 가로) +- **영상당 1장** 생성 (썸네일 겸용) + +### FLUX 프롬프트 예시 + +``` +makoto shinkai style, dreamy golden hour sky with layered clouds, +a peaceful meadow with a sleeping puppy curled up in soft grass, +warm sunlight filtering through trees, detailed sky gradients, +soft bokeh, cinematic wide shot composition, 8k quality, no text, no watermark +``` + +--- + +## 유튜브 업로드 설정 + +### 메타데이터 + +| 항목 | 값 | +|------|-----| +| 카테고리 | 음악 (10) | +| 공개 설정 | 즉시 공개 (public) | +| 재생목록 | **뮤직큐우** (`PLr8dPYZT-hCUjL-OgPxJdF81Dvn_g8Vbg`) | + +### 제목 형식 + +``` +🐕 강아지가 좋아하는 음악 2시간 | 수면음악, 분리불안 완화 [과학적 검증] +🐱 고양이가 편안해지는 음악 6시간 | 퍼링 주파수 기반 [수면, 휴식] +🐾 반려동물 힐링 음악 12시간 | 솔로 피아노, 스트레스 해소 +``` + +### 설명 (description) + +``` +{title} + +🎵 저작권 프리 음악 (Copyright Free) +본 음악은 AI로 생성된 저작권 프리 음악입니다. +개인/상업적 용도 모두 자유롭게 사용하실 수 있습니다. +출처 표기 없이 자유롭게 사용 가능합니다. + +🎵 Copyright Free Music +This music is AI-generated and copyright-free. +Free to use for any personal or commercial purpose. +No attribution required. + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🔬 과학적 근거 +• University of Glasgow + Scottish SPCA (2017): 레게/소프트록에서 개 스트레스 최소 +• Through a Dog's Ear (2008): 50-60 BPM 솔로 피아노에서 70%+ 진정 반응 +• Snowdon & Teie (2015): 고양이는 퍼링 주파수(25-50Hz)에 긍정 반응 + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🐾 반려동물 행동교정 플랫폼 애니밀리 +📲 앱 다운로드: 앱스토어/구글플레이에서 '애니밀리' 검색 +🌐 https://conimals.co.kr/ + +🤖 AI 생성 음악 (ACE-Step 1.5) + +#반려동물음악 #강아지수면음악 #고양이음악 #펫힐링 #저작권프리 +#dogmusic #catmusic #petrelaxation #copyrightfree #royaltyfree +``` + +### 고정 댓글 + +``` +🎵 저작권 프리 음악입니다! +개인/상업적 용도 모두 자유롭게 사용하세요. +출처 표기 불필요, 다운로드 자유 ✅ + +🎵 This is copyright-free music! +Free for personal and commercial use. +No attribution needed ✅ +``` + +--- + +## 프로젝트 구조 + +``` +/home/javamon/project/animily_music/ +├── config.py # 설정 (API URL, 경로, 채널 정보) +├── generate_music.py # ACE-Step API → 12세그먼트 생성 + 크로스페이드 +├── generate_image.py # FLUX (ComfyUI API) → 신카이 마코토 이미지 +├── render_video.py # 이미지 + 음악 + 워터마크 → ffmpeg 영상 +├── upload_youtube.py # YouTube Data API 롱폼 업로드 + 고정댓글 +├── scheduler.py # 메인 오케스트레이터 +├── prompts/ +│ ├── dog_music.json # 강아지 음악 프롬프트 (과학 기반) +│ ├── cat_music.json # 고양이 음악 프롬프트 (퍼링 기반) +│ └── image_prompts.json # FLUX 이미지 프롬프트 (신카이 스타일) +├── outputs/ # 임시 생성물 (영상 완성 후 정리) +├── logs/ +├── token_animily.pickle # YouTube OAuth token +├── requirements.txt +└── venv/ +``` + +--- + +## 모듈별 역할 + +### generate_music.py + +1. 프롬프트 로드 (dog/cat 랜덤 or 스케줄) +2. ACE-Step API에 10분 세그먼트 12개 순차 요청 +3. 각 세그먼트 동일 key/BPM, caption만 변주 +4. pydub로 5초 크로스페이드 이어붙이기 +5. 최종 ~119분 WAV 출력 + +### generate_image.py + +1. FLUX 프롬프트 로드 (신카이 마코토 + 반려동물) +2. ComfyUI API (localhost:8188) 호출 +3. 1920×1080 이미지 1장 생성 +4. 썸네일용 리사이즈 (1280×720) 별도 저장 + +### render_video.py + +1. ffmpeg: 이미지 → Ken Burns 효과 (미세 줌/패닝) +2. 워터마크 "ANIMILY" 우측 상단 오버레이 +3. 오디오 합성 +4. 6시간/12시간은 2시간 영상을 ffmpeg concat으로 반복 +5. 출력: MP4 (H.264 + AAC) + +### upload_youtube.py + +1. YouTube Data API v3 (OAuth2) +2. 영상 업로드 (public, 카테고리: 음악) +3. 재생목록 추가/생성 +4. 고정 댓글 작성 (저작권 프리 안내) + +### scheduler.py + +1. 콘텐츠 종류 결정 (강아지/고양이/혼합, 길이) +2. generate_music → generate_image → render_video → upload +3. 완료 후 outputs/ 정리 +4. 로그 기록 + +--- + +## 크론탭 + +```bash +# 반려동물 음악 롱폼 (매주 토요일 02:00) - 비활성 +# 0 2 * * 6 cd /home/javamon/project/animily_music && /home/javamon/project/animily_music/venv/bin/python scheduler.py >> /home/javamon/project/animily_music/logs/cron.log 2>&1 +``` + +--- + +## GPU 리소스 충돌 방지 + +- 기존 auto_shorts 스케줄: 01:01 (프리캐시) → 03:00 (영상 생성) +- animily_music: **토요일 02:00** → auto_shorts와 시간 겹침 없음 (토요일만) +- ACE-Step과 FLUX/ComfyUI는 순차 실행 (동시 사용 시 OOM) +- 순서: ACE-Step 음악 생성 → ACE-Step 종료 → ComfyUI FLUX 이미지 → 종료 → ffmpeg 렌더 + +--- + +## 콘텐츠 스케줄 (주 1회) + +| 주차 | 대상 | 길이 | 비고 | +|------|------|------|------| +| 1주 | 강아지 수면음악 | 2시간 | 솔로 피아노 | +| 2주 | 고양이 릴렉스 | 2시간 | 퍼링 앰비언트 | +| 3주 | 강아지 분리불안 | 6시간 | 소프트 레게 | +| 4주 | 반려동물 종합 | 12시간 | 하프 + 피아노 | + +→ 4주 사이클 반복, 프롬프트 변주로 매번 다른 곡 + +--- + +## 의존성 (requirements.txt) + +``` +pydub +google-auth +google-auth-oauthlib +google-api-python-client +requests +Pillow +``` + +--- + +## 참고 + +- 과학적 근거 상세: [PET_MUSIC_GENERATION.md](PET_MUSIC_GENERATION.md) +- 기존 쇼츠 파이프라인: `/home/javamon/project/auto_shorts/` (수정 금지) +- ACE-Step 문서: `/home/javamon/ACE-Step-1.5/README.md` + +--- + +*작성: 2026-04-21* diff --git a/generate_image.py b/generate_image.py new file mode 100755 index 0000000..57d60ae --- /dev/null +++ b/generate_image.py @@ -0,0 +1,192 @@ +"""Animily Music - FLUX 이미지 생성 (ComfyUI API) + +신카이 마코토 스타일 이미지 1장 생성 (1920x1080, 16:9 가로). +""" +import glob +import json +import os +import shutil +import signal +import subprocess +import time + +import requests + +from config import COMFYUI_URL, COMFYUI_DIR, OUTPUT_DIR, PROMPTS_DIR + + +def _start_comfyui(): + """ComfyUI FLUX 서버 시작 (8189 포트)""" + try: + requests.get(COMFYUI_URL, timeout=3) + print(" [FLUX] ComfyUI 이미 실행 중", flush=True) + return + except Exception: + pass + + # 기존 8188 (WAN) 종료 + try: + requests.get("http://localhost:8188", timeout=2) + result = subprocess.run(["fuser", "8188/tcp"], capture_output=True, text=True) + for p in result.stdout.strip().split(): + if p.strip().isdigit(): + os.kill(int(p.strip()), signal.SIGTERM) + time.sleep(5) + except Exception: + pass + + # FLUX ComfyUI 시작 + print(" [FLUX] ComfyUI 시작 중...", flush=True) + subprocess.Popen( + [f"{COMFYUI_DIR}/venv/bin/python3", "main.py", + "--listen", "0.0.0.0", "--port", "8189", "--bf16-vae", "--lowvram"], + cwd=COMFYUI_DIR, + stdout=open("/tmp/comfyui_flux_music.log", "a"), + stderr=open("/tmp/comfyui_flux_music.log", "a"), + ) + for _ in range(120): + try: + requests.get(COMFYUI_URL, timeout=2) + print(" [FLUX] ComfyUI 시작 완료", flush=True) + return + except Exception: + time.sleep(1) + raise RuntimeError("ComfyUI 시작 실패 (120초 타임아웃)") + + +def _stop_comfyui(): + """ComfyUI 종료 및 GPU 메모리 해제""" + try: + result = subprocess.run(["fuser", "8189/tcp"], capture_output=True, text=True) + for p in result.stdout.strip().split(): + if p.strip().isdigit(): + os.kill(int(p.strip()), signal.SIGTERM) + time.sleep(3) + # 강제 종료 + result2 = subprocess.run(["fuser", "8189/tcp"], capture_output=True, text=True) + for p in result2.stdout.strip().split(): + if p.strip().isdigit(): + try: + os.kill(int(p.strip()), signal.SIGKILL) + except Exception: + pass + time.sleep(5) + print(" [FLUX] ComfyUI 종료 + GPU 메모리 해제 완료", flush=True) + except Exception as e: + print(f" [FLUX] ComfyUI 종료 중 오류: {e}", flush=True) + + +def generate_image(prompt, output_path): + """FLUX로 이미지 생성 (1920x1080) + + Args: + prompt: 영어 이미지 프롬프트 + output_path: 출력 파일 경로 + + Returns: + bool: 성공 여부 + """ + _start_comfyui() + + # 이전 출력 정리 + for f in glob.glob(f"{COMFYUI_DIR}/output/flux_gen_*.png"): + os.remove(f) + + # FLUX.2 워크플로우 (1920x1080) + 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", + }}, + "4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["3", 0], "text": prompt}}, + "5": {"class_type": "EmptyLatentImage", "inputs": { + "width": 1024, "height": 576, "batch_size": 1, + }}, + "6": {"class_type": "KSampler", "inputs": { + "model": ["2", 0], "positive": ["4", 0], "negative": ["4", 0], + "latent_image": ["5", 0], + "seed": int(time.time()) % 999999, "steps": 8, "cfg": 1.0, + "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, + }}, + "7": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-ae.safetensors"}}, + "8": {"class_type": "VAEDecodeTiled", "inputs": { + "vae": ["7", 0], "samples": ["6", 0], + "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8, + }}, + "9": {"class_type": "SaveImage", "inputs": { + "images": ["8", 0], "filename_prefix": "flux_gen", + }}, + } + + print(f" [FLUX] 이미지 생성 중...", flush=True) + t0 = time.time() + + try: + resp = requests.post(f"{COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=10) + if resp.status_code != 200: + print(f" [FLUX] 워크플로우 에러: {resp.text[:200]}", flush=True) + return False + + pid = resp.json().get("prompt_id") + + # 완료 대기 (최대 15분) + while (time.time() - t0) < 900: + h = requests.get(f"{COMFYUI_URL}/history/{pid}", timeout=5).json() + if pid in h: + status = h[pid].get("status", {}).get("status_str", "") + if status == "success": + pngs = sorted(glob.glob(f"{COMFYUI_DIR}/output/flux_gen_*.png")) + if pngs: + shutil.copy2(pngs[-1], output_path) + for p in pngs: + os.remove(p) + elapsed = time.time() - t0 + print(f" [FLUX] 이미지 생성 완료: {elapsed:.0f}초", flush=True) + return True + return False + elif status == "error": + print(f" [FLUX] 생성 에러", flush=True) + return False + time.sleep(5) + + print(f" [FLUX] 타임아웃 (15분)", flush=True) + return False + + except Exception as e: + print(f" [FLUX] 오류: {e}", flush=True) + return False + + +def generate_thumbnail(image_path, thumbnail_path): + """1280x720 썸네일 생성""" + cmd = [ + "ffmpeg", "-y", "-i", image_path, + "-vf", "scale=1280:720", + "-q:v", "2", thumbnail_path, + ] + subprocess.run(cmd, capture_output=True) + return os.path.exists(thumbnail_path) + + +def load_image_prompt(animal_type="dog"): + """이미지 프롬프트 로드""" + path = os.path.join(PROMPTS_DIR, "image_prompts.json") + with open(path) as f: + data = json.load(f) + prompts = data.get(animal_type, data.get("dog")) + return prompts[int(time.time()) % len(prompts)] + + +if __name__ == "__main__": + import sys + animal = sys.argv[1] if len(sys.argv) > 1 else "dog" + prompt = load_image_prompt(animal) + out = os.path.join(OUTPUT_DIR, f"test_image_{animal}.png") + ok = generate_image(prompt, out) + _stop_comfyui() + print(f"결과: {ok}, {out}") diff --git a/generate_music.py b/generate_music.py new file mode 100755 index 0000000..c9c6cb7 --- /dev/null +++ b/generate_music.py @@ -0,0 +1,249 @@ +"""Animily Music - ACE-Step 음악 생성 + +12개 세그먼트(각 ~595초) 생성 후 크로스페이드로 이어붙여 ~119분 완성. +""" +import json +import os +import subprocess +import time +import urllib.parse + +import requests + +from config import ( + ACESTEP_URL, OUTPUT_DIR, PROMPTS_DIR, + SEGMENT_DURATION, SEGMENTS_FOR_2H, CROSSFADE_SEC, BATCH_SIZE, +) + + +def _wait_for_acestep(timeout=120): + """ACE-Step API 서버 health 확인""" + for _ in range(timeout): + try: + r = requests.get(f"{ACESTEP_URL}/health", timeout=3) + if r.status_code == 200: + return True + except Exception: + pass + time.sleep(1) + return False + + +def _submit_task(caption, bpm, keyscale, duration): + """ACE-Step에 생성 작업 제출""" + payload = { + "think": True, + "caption": caption, + "lyrics": "[Instrumental]", + "bpm": bpm, + "duration": duration, + "keyscale": keyscale, + "language": "instrumental", + "timesignature": "4", + "batch_size": BATCH_SIZE, + } + r = requests.post(f"{ACESTEP_URL}/release_task", json=payload, timeout=30) + r.raise_for_status() + data = r.json() + task_id = data.get("data", {}).get("task_id") + if not task_id: + raise RuntimeError(f"task_id를 받지 못했습니다: {data}") + return task_id + + +def _poll_result(task_id, timeout=900): + """작업 완료 대기 및 오디오 URL 반환""" + t0 = time.time() + while time.time() - t0 < timeout: + try: + r = requests.post( + f"{ACESTEP_URL}/query_result", + json={"task_id_list": [task_id]}, + timeout=10, + ) + items = r.json().get("data", []) + if not items: + time.sleep(10) + continue + + item = items[0] + status = item.get("status") + + if status == 1: # completed + # result는 JSON 문자열 + result_str = item.get("result", "[]") + try: + result_list = json.loads(result_str) if isinstance(result_str, str) else result_str + except json.JSONDecodeError: + result_list = [] + + if result_list and isinstance(result_list, list): + # 첫 번째 결과의 file URL 반환 + file_url = result_list[0].get("file", "") + if file_url: + return file_url + raise RuntimeError(f"완료됐으나 오디오 경로 없음: {item}") + + elif status == 2: # failed/timeout + raise RuntimeError(f"생성 실패: {item}") + + # status == 0: running + except requests.RequestException: + pass + time.sleep(10) + + raise TimeoutError(f"task {task_id} 타임아웃 ({timeout}초)") + + +def _download_audio(file_url, output_path): + """ACE-Step /v1/audio 엔드포인트에서 오디오 다운로드""" + # file_url 형태: "/v1/audio?path=%2Fhome%2F..." + 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)}" + + r = requests.get(download_url, timeout=120) + r.raise_for_status() + + with open(output_path, "wb") as f: + f.write(r.content) + + if not os.path.exists(output_path) or os.path.getsize(output_path) < 1000: + raise RuntimeError(f"다운로드 실패 또는 빈 파일: {output_path}") + print(f" 다운로드: {os.path.getsize(output_path) // 1024}KB") + + +def _crossfade_segments(segment_paths, output_path, crossfade_ms=5000): + """ffmpeg로 세그먼트들을 크로스페이드하여 이어붙이기""" + if len(segment_paths) == 1: + subprocess.run(["cp", segment_paths[0], output_path], check=True) + return + + current = segment_paths[0] + + for i, next_seg in enumerate(segment_paths[1:], 1): + tmp_out = os.path.join(OUTPUT_DIR, f"_merge_{i}.wav") + cmd = [ + "ffmpeg", "-y", + "-i", current, + "-i", next_seg, + "-filter_complex", + f"acrossfade=d={crossfade_ms // 1000}:c1=tri:c2=tri", + "-c:a", "pcm_s16le", + tmp_out, + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + # 폴백: 단순 concat + cmd_concat = [ + "ffmpeg", "-y", + "-i", current, + "-i", next_seg, + "-filter_complex", "[0:a][1:a]concat=n=2:v=0:a=1[out]", + "-map", "[out]", "-c:a", "pcm_s16le", + tmp_out, + ] + subprocess.run(cmd_concat, capture_output=True) + + if current.startswith(os.path.join(OUTPUT_DIR, "_merge_")): + os.remove(current) + current = tmp_out + + # 마지막 페이드아웃 + probe = subprocess.run( + ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", current], + capture_output=True, text=True, + ) + try: + total_dur = float(probe.stdout.strip()) + fade_start = max(0, total_dur - 5) + except ValueError: + fade_start = 7100 + + cmd_fade = [ + "ffmpeg", "-y", "-i", current, + "-af", f"afade=t=out:st={fade_start}:d=5", + "-c:a", "pcm_s16le", output_path, + ] + subprocess.run(cmd_fade, capture_output=True) + if current.startswith(os.path.join(OUTPUT_DIR, "_merge_")): + os.remove(current) + + print(f" [MUSIC] 최종 음악 완성: {output_path}") + + +def _load_prompts(animal_type): + """프롬프트 파일 로드 (dog or cat)""" + path = os.path.join(PROMPTS_DIR, f"{animal_type}_music.json") + with open(path) as f: + return json.load(f) + + +def generate_2h_music(animal_type="dog", style_index=0): + """2시간 음악 생성 (12세그먼트) + + Returns: + str: 최종 음악 파일 경로 + """ + prompts = _load_prompts(animal_type) + style = prompts["styles"][style_index % len(prompts["styles"])] + + base_caption = style["caption"] + bpm = style["bpm"] + keyscale = style["keyscale"] + variations = style.get("variations", []) + + print(f"\n{'='*60}") + print(f"[MUSIC] {animal_type} 2시간 음악 생성 ({SEGMENTS_FOR_2H}세그먼트)") + print(f" Style: {style['name']}, BPM: {bpm}, Key: {keyscale}") + print(f"{'='*60}\n") + + if not _wait_for_acestep(): + raise RuntimeError("ACE-Step API 서버에 연결할 수 없습니다") + + segment_paths = [] + for i in range(SEGMENTS_FOR_2H): + seg_path = os.path.join(OUTPUT_DIR, f"seg_{animal_type}_{i:02d}.wav") + + if variations: + var = variations[i % len(variations)] + caption = f"{base_caption} {var}" + else: + caption = base_caption + + print(f" [SEG {i+1}/{SEGMENTS_FOR_2H}] 생성 중...", flush=True) + t0 = time.time() + + try: + task_id = _submit_task(caption, bpm, keyscale, SEGMENT_DURATION) + file_url = _poll_result(task_id, timeout=900) + _download_audio(file_url, seg_path) + elapsed = time.time() - t0 + print(f" [SEG {i+1}/{SEGMENTS_FOR_2H}] 완료 ({elapsed:.0f}초)") + segment_paths.append(seg_path) + except Exception as e: + print(f" [SEG {i+1}] 실패: {e}") + continue + + if len(segment_paths) < 6: + raise RuntimeError(f"세그먼트 {len(segment_paths)}개만 성공 — 최소 6개 필요") + + output_path = os.path.join(OUTPUT_DIR, f"music_{animal_type}_2h.wav") + _crossfade_segments(segment_paths, output_path, CROSSFADE_SEC * 1000) + + for p in segment_paths: + if os.path.exists(p): + os.remove(p) + + return output_path + + +if __name__ == "__main__": + import sys + animal = sys.argv[1] if len(sys.argv) > 1 else "dog" + result = generate_2h_music(animal) + print(f"\n완료: {result}") diff --git a/prompts/cat_music.json b/prompts/cat_music.json new file mode 100755 index 0000000..a081170 --- /dev/null +++ b/prompts/cat_music.json @@ -0,0 +1,32 @@ +{ + "styles": [ + { + "name": "퍼링 기반 앰비언트", + "caption": "Ultra-soft ambient piece with cello harmonics and gentle violin in higher register (1000-1600Hz range). Purring-like low drone undertone. Sliding portamento notes mimicking cat vocalizations. Extremely quiet, meditative, no rhythm section. Slow glissando strings, warm ethereal pad. No percussion, no sudden dynamic changes. Based on feline acoustic frequency preferences from Snowdon and Teie research.", + "bpm": 60, + "keyscale": "A major", + "variations": [ + "Cello harmonics with gentle sustain and natural vibrato.", + "High violin sliding notes with warm pad underneath.", + "Ethereal strings with subtle purring-rhythm pulse.", + "Soft sustained tones with gradual pitch bending.", + "Gentle overtone harmonics floating above warm drone.", + "Delicate pizzicato patterns with long reverb tail." + ] + }, + { + "name": "고양이 자장가 (분리불안용)", + "caption": "Delicate lullaby for cats with soft violin playing in the 1000-1500Hz range, mimicking feline vocal frequency. Gentle sustained notes with natural vibrato and portamento slides. Background warm pad with subtle purring rhythm (25-30Hz pulse). Extremely minimal, quiet, and predictable. No low bass, no percussion, no sharp attacks. Soothing and hypnotic repetition.", + "bpm": 55, + "keyscale": "D major", + "variations": [ + "Violin melody with slow portamento and gentle vibrato.", + "Warm pad with subtle rhythmic pulse mimicking purring.", + "High register sustained notes fading into silence.", + "Simple melodic fragments repeated with slight variation.", + "Gentle bowing sounds with natural harmonic overtones.", + "Soft drone with occasional high-pitched melodic ornaments." + ] + } + ] +} diff --git a/prompts/dog_music.json b/prompts/dog_music.json new file mode 100755 index 0000000..42c1690 --- /dev/null +++ b/prompts/dog_music.json @@ -0,0 +1,46 @@ +{ + "styles": [ + { + "name": "솔로 피아노 (최대 진정)", + "caption": "Extremely gentle solo piano piece. Warm, soft touch with narrow dynamic range (pp to mp). Simple stepwise melody with small intervals, repetitive soothing phrases. Consonant harmony only, simple I-IV-V progression. Mid-range frequencies dominant (200Hz-4kHz). No bass rumble, no high-frequency shimmer. Gradual transitions, meditative and peaceful. Designed for canine relaxation based on psychoacoustic research.", + "bpm": 58, + "keyscale": "C major", + "variations": [ + "Gentle ascending phrases with soft pedal resonance.", + "Descending melodic pattern with warm sustained notes.", + "Arpeggiated chords in middle register, very slow and deliberate.", + "Simple two-note patterns alternating with brief silences.", + "Legato melody in the tenor range with minimal accompaniment.", + "Soft repeated chord progression with gentle dynamic swells." + ] + }, + { + "name": "소프트 레게 (스트레스 감소)", + "caption": "Gentle soft reggae instrumental with acoustic guitar offbeat skank and warm piano chords. Light bass in 100-250Hz range, no drums or percussion. Simple repetitive melody, tropical and mellow. Very narrow dynamic range, smooth and flowing with no sudden changes. Mid-range focused, relaxed and soothing atmosphere. Based on University of Glasgow canine stress research.", + "bpm": 68, + "keyscale": "G major", + "variations": [ + "Acoustic guitar offbeat with gentle piano fills.", + "Warm bass line with soft melodic guitar over the top.", + "Piano-led section with subtle string pad in background.", + "Guitar melody in middle register, very smooth and flowing.", + "Soft keyboard chords with gentle rhythmic acoustic guitar.", + "Simple bass and guitar interplay, warm and tropical feeling." + ] + }, + { + "name": "하프 클래식 (수의사 방문 전)", + "caption": "Solo harp playing simplified classical arrangements. Gentle arpeggios with warm resonance, extremely soft dynamics. Predictable harmonic rhythm, no tempo changes. Narrow frequency range focused on mid-register. Calming, ethereal, spa-like atmosphere. Slow and deliberate with natural ring-out between phrases.", + "bpm": 54, + "keyscale": "F major", + "variations": [ + "Descending arpeggios with gentle resonance fading naturally.", + "Simple ascending scale patterns with soft harmonic overtones.", + "Repeated gentle chord shapes with slight melodic variation.", + "Low register arpeggios with warm, rounded tone.", + "Middle register melody with delicate ornamental notes.", + "Sustained notes with natural decay, very peaceful and still." + ] + } + ] +} diff --git a/prompts/image_prompts.json b/prompts/image_prompts.json new file mode 100755 index 0000000..754b628 --- /dev/null +++ b/prompts/image_prompts.json @@ -0,0 +1,17 @@ +{ + "dog": [ + "makoto shinkai style, dreamy golden hour sky with layered clouds, a peaceful meadow with a sleeping golden retriever puppy curled up in soft grass, warm sunlight filtering through trees, detailed sky gradients, soft bokeh, cinematic wide shot composition, 8k quality, no text, no watermark", + "makoto shinkai style, serene autumn park with falling leaves, a happy shiba inu resting under a large oak tree, golden afternoon light, lens flare, detailed foliage, peaceful atmosphere, cinematic landscape, 8k wallpaper quality, no text, no watermark", + "makoto shinkai style, beautiful sunrise over calm lake, a small dog sleeping peacefully on wooden dock, misty morning atmosphere, pink and orange sky reflections on water, ultra detailed clouds, cinematic wide angle, 8k quality, no text, no watermark", + "makoto shinkai style, lush green hillside with wildflowers, a corgi napping in a field of lavender, soft diffused sunlight, butterflies in background, dreamy pastel colors, detailed grass textures, wide landscape shot, 8k quality, no text, no watermark", + "makoto shinkai style, peaceful countryside with distant mountains, a sleeping beagle on a porch overlooking valley, warm sunset colors, detailed cloud formations, cozy rural atmosphere, cinematic composition, 8k quality, no text, no watermark", + "makoto shinkai style, cherry blossom garden in full bloom, a white poodle resting under sakura tree, petals floating in gentle breeze, soft pink lighting, ethereal atmosphere, detailed blossom textures, wide shot, 8k quality, no text, no watermark" + ], + "cat": [ + "makoto shinkai style, serene moonlit room with soft blue light streaming through window, a tabby cat peacefully sleeping on windowsill, cherry blossoms visible outside, stars in night sky, atmospheric lighting, detailed reflections, cinematic composition, 8k quality, no text, no watermark", + "makoto shinkai style, cozy sunset library with warm golden light, a grey cat curled up on cushion near tall window, bookshelves in background, dust particles in light beams, peaceful indoor scene, 8k quality, no text, no watermark", + "makoto shinkai style, rainy afternoon with soft light, a calico cat sleeping by rain-streaked window, city lights blurred in background, gentle water droplets on glass, warm interior atmosphere, cinematic, 8k quality, no text, no watermark", + "makoto shinkai style, starlit balcony with potted plants, a black cat gazing at night sky, aurora borealis in distance, detailed constellation patterns, magical atmosphere, soft ambient lighting, wide shot, 8k quality, no text, no watermark", + "makoto shinkai style, peaceful morning sunroom filled with plants, an orange cat stretching lazily in sunbeam, dust particles floating in light, lush greenery, warm golden tones, serene domestic scene, 8k quality, no text, no watermark" + ] +} diff --git a/render_video.py b/render_video.py new file mode 100755 index 0000000..16c74ab --- /dev/null +++ b/render_video.py @@ -0,0 +1,185 @@ +"""Animily Music - 영상 렌더링 + +이미지 + 음악 + 워터마크 → MP4 영상. +Ken Burns 효과 + ANIMILY 워터마크 (우측 상단, 영상 전체). +""" +import os +import subprocess + +from config import ( + OUTPUT_DIR, WATERMARK_TEXT, WATERMARK_FONT, WATERMARK_SIZE, +) + + +def render_video(image_path, audio_path, output_path, title=None): + """이미지 + 오디오 → 워터마크 포함 영상 렌더링 + + Args: + image_path: 배경 이미지 (1920x1080) + audio_path: 오디오 파일 + output_path: 출력 MP4 경로 + title: (미사용, 향후 텍스트 오버레이용) + + Returns: + bool: 성공 여부 + """ + # 오디오 길이 측정 + probe = subprocess.run( + ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", audio_path], + capture_output=True, text=True, + ) + try: + duration = float(probe.stdout.strip()) + except ValueError: + print(f" [RENDER] 오디오 길이 측정 실패", flush=True) + return False + + print(f" [RENDER] 영상 렌더링 시작 (길이: {duration/60:.1f}분)", flush=True) + + # Ken Burns: 매우 느린 줌인 (119분 동안 1.0 → 1.08 정도) + # zoompan fps=30, duration=전체 프레임 수 + total_frames = int(duration * 30) + + # 워터마크 필터 + watermark_filter = ( + f"drawtext=text='{WATERMARK_TEXT}':" + f"fontfile={WATERMARK_FONT}:" + f"fontsize={WATERMARK_SIZE}:" + f"fontcolor=white@0.4:" + f"x=w-tw-80:y=40:" + f"shadowcolor=black@0.3:shadowx=1:shadowy=1" + ) + + # Ken Burns + 워터마크 결합 + # zoompan으로 미세 줌인 효과 + video_filter = ( + f"zoompan=z='min(zoom+0.00001,1.08)':" + f"d={total_frames}:s=1920x1080:fps=30," + f"{watermark_filter}" + ) + + cmd = [ + "ffmpeg", "-y", + "-loop", "1", "-i", image_path, + "-i", audio_path, + "-vf", video_filter, + "-c:v", "libx264", "-preset", "medium", "-crf", "20", + "-tune", "stillimage", + "-c:a", "aac", "-b:a", "192k", + "-shortest", + "-pix_fmt", "yuv420p", + output_path, + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) + if result.returncode != 0: + print(f" [RENDER] ffmpeg 에러: {result.stderr[:500]}", flush=True) + # 폴백: Ken Burns 없이 단순 정지 이미지 + cmd_simple = [ + "ffmpeg", "-y", + "-loop", "1", "-i", image_path, + "-i", audio_path, + "-vf", watermark_filter, + "-c:v", "libx264", "-preset", "medium", "-crf", "20", + "-tune", "stillimage", + "-c:a", "aac", "-b:a", "192k", + "-shortest", + "-pix_fmt", "yuv420p", + output_path, + ] + result = subprocess.run(cmd_simple, capture_output=True, text=True, timeout=7200) + if result.returncode != 0: + print(f" [RENDER] 폴백도 실패: {result.stderr[:300]}", flush=True) + return False + + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f" [RENDER] 완료: {output_path} ({size_mb:.0f}MB)", flush=True) + return True + + +def extend_video(short_video, target_hours, output_path): + """��은 영상을 반복하여 긴 영상 생성 + + Args: + short_video: 원본 영상 (1h or 2h) + target_hours: 목표 시간 + output_path: 출력 경로 + + Returns: + bool: 성공 여부 + """ + # 원본 길이 측정 + probe = subprocess.run( + ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", short_video], + capture_output=True, text=True, + ) + src_duration = float(probe.stdout.strip()) + target_seconds = target_hours * 3600 + + # 필요 반복 횟�� + repeats = int(target_seconds / src_duration) + 1 + + print(f" [RENDER] 영상 반복: {repeats}회 → {target_hours}시간", flush=True) + + # concat 파일 생성 + concat_file = os.path.join(OUTPUT_DIR, "_concat_list.txt") + with open(concat_file, "w") as f: + for _ in range(repeats): + f.write(f"file '{short_video}'\n") + + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", "-i", concat_file, + "-t", str(int(target_seconds)), + "-c", "copy", + output_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) + os.remove(concat_file) + + if result.returncode != 0: + print(f" [RENDER] 반복 영상 실패: {result.stderr[:300]}", flush=True) + return False + + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f" [RENDER] 반복 영상 완료: {size_mb:.0f}MB ({target_hours}h)", flush=True) + return True + + +def concat_videos(video_paths, output_path): + """여러 영상을 순서대로 이어붙이기 (12시간용: 1h+2h 반복) + + Args: + video_paths: 이어붙일 영상 리스트 + output_path: 출력 경로 + + Returns: + bool: 성공 여부 + """ + concat_file = os.path.join(OUTPUT_DIR, "_concat_mix.txt") + with open(concat_file, "w") as f: + for vp in video_paths: + f.write(f"file '{vp}'\n") + + cmd = [ + "ffmpeg", "-y", + "-f", "concat", "-safe", "0", "-i", concat_file, + "-c", "copy", + output_path, + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) + os.remove(concat_file) + + if result.returncode != 0: + print(f" [RENDER] concat 실패: {result.stderr[:300]}", flush=True) + return False + + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f" [RENDER] concat 완료: {size_mb:.0f}MB", flush=True) + return True + + +if __name__ == "__main__": + print("render_video.py - 직접 실행은 scheduler.py를 사용하세요") diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000..6217bf1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pydub +google-auth +google-auth-oauthlib +google-api-python-client +requests +Pillow diff --git a/scheduler.py b/scheduler.py new file mode 100755 index 0000000..1dcdf32 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Animily Music - 메인 스케줄러 + +1시간 / 2시간 / 12시간(1h+2h 혼합×4) 영상 생성 → 유튜브 업로드. +GPU 메모리 관리: ACE-Step → 종료 → FLUX → 종료 → ffmpeg. +""" +import gc +import os +import signal +import subprocess +import sys +import time +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config import OUTPUT_DIR, LOG_DIR, SEGMENTS_FOR_1H, SEGMENTS_FOR_2H +from generate_music import generate_2h_music, _wait_for_acestep +from generate_image import generate_image, load_image_prompt, _stop_comfyui, generate_thumbnail +from render_video import render_video, concat_videos +from upload_youtube import upload_video, create_or_get_playlist, add_to_playlist + + +def _kill_acestep(): + """ACE-Step 서버 관련 프로세스 GPU 메모리 해제 확인""" + # ACE-Step은 외부 서버이므로 직접 종료하지 않음 + # 다만 GPU 메모리가 해제될 때까지 대기 + print(" [GPU] ACE-Step 작업 완료, GPU 메모리 해제 대기...", flush=True) + time.sleep(10) + gc.collect() + + +def _cleanup_outputs(*paths): + """임시 파일 정리""" + for p in paths: + if p and os.path.exists(p): + os.remove(p) + print(f" [CLEAN] 삭제: {os.path.basename(p)}", flush=True) + + +def generate_1h_music(animal_type="dog", style_index=0): + """1시간 음악 생성""" + from generate_music import ( + _load_prompts, _submit_task, _poll_result, + _download_audio, _crossfade_segments, + ) + from config import SEGMENT_DURATION, CROSSFADE_SEC + + prompts = _load_prompts(animal_type) + style = prompts["styles"][style_index % len(prompts["styles"])] + + base_caption = style["caption"] + bpm = style["bpm"] + keyscale = style["keyscale"] + variations = style.get("variations", []) + + segments_needed = SEGMENTS_FOR_1H # 12 * 295s ≈ 59분 + + print(f"\n{'='*60}") + print(f"[MUSIC] {animal_type} 1시간 음악 생성 ({segments_needed}세그먼트)") + print(f"{'='*60}\n") + + segment_paths = [] + for i in range(segments_needed): + seg_path = os.path.join(OUTPUT_DIR, f"seg_1h_{animal_type}_{i:02d}.wav") + + if variations: + var = variations[i % len(variations)] + caption = f"{base_caption} {var}" + else: + caption = base_caption + + print(f" [SEG {i+1}/{segments_needed}] 생성 중...", flush=True) + t0 = time.time() + try: + task_id = _submit_task(caption, bpm, keyscale, SEGMENT_DURATION) + audio_info = _poll_result(task_id, timeout=900) + _download_audio(audio_info, seg_path) + print(f" [SEG {i+1}/{segments_needed}] 완료 ({time.time()-t0:.0f}초)") + segment_paths.append(seg_path) + except Exception as e: + print(f" [SEG {i+1}] 실패: {e}") + continue + + if len(segment_paths) < 6: + raise RuntimeError(f"1시간 음악: {len(segment_paths)}개만 성공 — 최소 6개 필요") + + output_path = os.path.join(OUTPUT_DIR, f"music_{animal_type}_1h.wav") + _crossfade_segments(segment_paths, output_path, CROSSFADE_SEC * 1000) + + for p in segment_paths: + if os.path.exists(p): + os.remove(p) + + return output_path + + +def run_pipeline(): + """전체 파이프라인 실행 + + 생성할 영상: + 1. 1시간짜리 (강아지 솔로 피아노) + 2. 2시간짜리 (강아지 소프트 레게) + 3. 12시간짜리 = (1시간 + 2시간) × 4 반복 + """ + start_time = time.time() + today = datetime.now().strftime("%Y%m%d") + + print(f"\n{'#'*60}") + print(f"# Animily Music Pipeline - {today}") + print(f"{'#'*60}\n") + + results = [] + video_1h_path = None + video_2h_path = None + + # ============================================================ + # STEP 1: ACE-Step 음악 생성 (1시간 + 2시간) + # ============================================================ + print("\n[STEP 1] 음악 생성 (ACE-Step)") + print("=" * 40) + + if not _wait_for_acestep(timeout=30): + # ACE-Step 서버 시작 시도 + print(" [MUSIC] ACE-Step 서버 시작 중...", flush=True) + 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=open(os.path.join(LOG_DIR, "acestep.log"), "a"), + stderr=open(os.path.join(LOG_DIR, "acestep.log"), "a"), + ) + if not _wait_for_acestep(timeout=180): + print(" [ERROR] ACE-Step 서버 시작 실패!", flush=True) + return + + # 1시간 음악 (강아지 - 솔로 피아노, style_index=0) + music_1h = generate_1h_music("dog", style_index=0) + print(f" [MUSIC] 1시간 음악 완료: {music_1h}") + + # 2시간 음악 (강아지 - 소프트 레게, style_index=1) + music_2h = generate_2h_music("dog", style_index=1) + print(f" [MUSIC] 2시간 음악 완료: {music_2h}") + + # ACE-Step 작업 완료 → GPU 해제 대기 + _kill_acestep() + + # ============================================================ + # STEP 2: FLUX 이미지 생성 (영상당 1장) + # ============================================================ + print("\n[STEP 2] 이미지 생성 (FLUX)") + print("=" * 40) + + image_1h = os.path.join(OUTPUT_DIR, f"image_1h_{today}.png") + image_2h = os.path.join(OUTPUT_DIR, f"image_2h_{today}.png") + thumb_1h = os.path.join(OUTPUT_DIR, f"thumb_1h_{today}.jpg") + thumb_2h = os.path.join(OUTPUT_DIR, f"thumb_2h_{today}.jpg") + + # 1시간용 이미지 + prompt_1h = load_image_prompt("dog") + if not generate_image(prompt_1h, image_1h): + print(" [WARN] 1시간 이미지 생성 실패, 2시간 이미지 공유", flush=True) + + # 2시간용 이미지 (다른 프롬프트) + prompt_2h = load_image_prompt("dog") + if not generate_image(prompt_2h, image_2h): + # 1시간 이미지를 재사용 + if os.path.exists(image_1h): + subprocess.run(["cp", image_1h, image_2h]) + + # 썸네일 생성 + if os.path.exists(image_1h): + generate_thumbnail(image_1h, thumb_1h) + if os.path.exists(image_2h): + generate_thumbnail(image_2h, thumb_2h) + + # FLUX/ComfyUI 종료 → GPU 메모리 해제 + _stop_comfyui() + + # ============================================================ + # STEP 3: 영상 렌더링 (ffmpeg) + # ============================================================ + print("\n[STEP 3] 영상 렌더링") + print("=" * 40) + + # 사용할 이미지 결정 + img_for_1h = image_1h if os.path.exists(image_1h) else image_2h + img_for_2h = image_2h if os.path.exists(image_2h) else image_1h + + # 1시간 영상 + video_1h_path = os.path.join(OUTPUT_DIR, f"pet_music_1h_{today}.mp4") + if render_video(img_for_1h, music_1h, video_1h_path): + print(f" [VIDEO] 1시간 영상 완료") + else: + print(f" [ERROR] 1시간 영상 렌더 실패!") + + # 2시간 영상 + video_2h_path = os.path.join(OUTPUT_DIR, f"pet_music_2h_{today}.mp4") + if render_video(img_for_2h, music_2h, video_2h_path): + print(f" [VIDEO] 2시간 영상 완료") + else: + print(f" [ERROR] 2시간 영상 렌더 실패!") + + # 12시간 영상 = (1h + 2h) × 4 반복 + video_12h_path = os.path.join(OUTPUT_DIR, f"pet_music_12h_{today}.mp4") + if video_1h_path and video_2h_path and os.path.exists(video_1h_path) and os.path.exists(video_2h_path): + # 1h + 2h = 3h, × 4 = 12h + mix_list = [video_1h_path, video_2h_path] * 4 + if concat_videos(mix_list, video_12h_path): + print(f" [VIDEO] 12시간 영상 완료") + else: + print(f" [ERROR] 12시간 영상 렌더 실패!") + + # ============================================================ + # STEP 4: YouTube 업로드 + # ============================================================ + print("\n[STEP 4] YouTube 업로드") + print("=" * 40) + + playlist_id = create_or_get_playlist() + + # 1시간 업로드 + if video_1h_path and os.path.exists(video_1h_path): + title_1h = "🐕 강아지가 좋아하는 음악 1시간 | 솔로 피아노 수면음악 [과학적 검증]" + vid_id = upload_video( + video_1h_path, title_1h, + thumbnail_path=thumb_1h if os.path.exists(thumb_1h) else None, + extra_tags=["1시간", "솔로피아노", "수면"], + ) + if vid_id and playlist_id: + add_to_playlist(vid_id, playlist_id) + results.append(("1시간", vid_id)) + + # 2시간 업로드 + if video_2h_path and os.path.exists(video_2h_path): + title_2h = "🐕 강아지가 좋아하는 음악 2시간 | 소프트 레게, 분리불안 완화 [과학적 검증]" + vid_id = upload_video( + video_2h_path, title_2h, + thumbnail_path=thumb_2h if os.path.exists(thumb_2h) else None, + extra_tags=["2시간", "레게", "분리불안"], + ) + if vid_id and playlist_id: + add_to_playlist(vid_id, playlist_id) + results.append(("2시간", vid_id)) + + # 12시간 업로드 + if os.path.exists(video_12h_path): + title_12h = "🐕 강아지가 좋아하는 음악 12시간 | 수면, 분리불안, 스트레스 해소 [과학적 검증]" + vid_id = upload_video( + video_12h_path, title_12h, + thumbnail_path=thumb_2h if os.path.exists(thumb_2h) else None, + extra_tags=["12시간", "수면음악", "분리불안", "장시간"], + ) + if vid_id and playlist_id: + add_to_playlist(vid_id, playlist_id) + results.append(("12시간", vid_id)) + + # ============================================================ + # STEP 5: 정리 + # ============================================================ + print("\n[STEP 5] 정리") + print("=" * 40) + + # 임시 음악 파일 삭제 (영상에 포함됐으므로) + _cleanup_outputs(music_1h, music_2h, image_1h, image_2h, thumb_1h, thumb_2h) + # 영상 파일은 업로드 확인 후 삭제 + for label, vid_id in results: + if vid_id: + print(f" [{label}] 업로드 성공 → 영상 파일 삭제") + # 업로드 성공한 영상만 삭제 + if results: + for path in [video_1h_path, video_2h_path, video_12h_path]: + if path and os.path.exists(path): + os.remove(path) + print(f" [CLEAN] {os.path.basename(path)} 삭제") + + # GPU 메모리 최종 해제 확인 + gc.collect() + + elapsed = time.time() - start_time + print(f"\n{'#'*60}") + print(f"# 파이프라인 완료 ({elapsed/60:.1f}분)") + print(f"# 결과:") + for label, vid_id in results: + url = f"https://youtu.be/{vid_id}" if vid_id else "실패" + print(f"# {label}: {url}") + print(f"{'#'*60}\n") + + +if __name__ == "__main__": + run_pipeline() diff --git a/upload_youtube.py b/upload_youtube.py new file mode 100755 index 0000000..7e02d44 --- /dev/null +++ b/upload_youtube.py @@ -0,0 +1,209 @@ +"""Animily Music - YouTube 업로드 + +독립 YouTube 업로드 모듈. 기존 auto_shorts의 토큰을 공유하되 코드는 독립. +""" +import os +import pickle + +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from googleapiclient.http import MediaFileUpload + +from config import TOKEN_PATH, YOUTUBE_CATEGORY_ID + + +DESCRIPTION_TEMPLATE = """{title} + +🎵 저작권 프리 음악 (Copyright Free) +본 음악은 AI로 생성된 저작권 프리 음악입니다. +개인/상업적 용도 모두 자유롭게 사용하실 수 있습니다. +출처 표기 없이 자유롭게 사용 가능합니다. + +🎵 Copyright Free Music +This music is AI-generated and copyright-free. +Free to use for any personal or commercial purpose. +No attribution required. + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🔬 과학적 근거 +• University of Glasgow + Scottish SPCA (2017): 레게/소프트록에서 개 스트레스 최소 +• Through a Dog's Ear (2008): 50-60 BPM 솔로 피아노에서 70%+ 진정 반응 +• Snowdon & Teie (2015): 고양이는 퍼링 주파수(25-50Hz)에 긍정 반응 + +━━━━━━━━━━━━━━━━━━━━━━━━ + +🐾 반려동물 행동교정 플랫폼 애니밀리 +📲 앱 다운로드: 앱스토어/구글플레이에서 '애니밀리' 검색 +🌐 https://conimals.co.kr/ + +🤖 AI 생성 음악 (ACE-Step 1.5) + +#반려동물음악 #강아지수면음악 #고양이음악 #펫힐링 #저작권프리 +#dogmusic #catmusic #petrelaxation #copyrightfree #royaltyfree""" + +FIRST_COMMENT = """🎵 저작권 프리 음악입니다! +개인/상업적 용도 모두 자유롭게 사용하세요. +출처 표기 불필요, 다운로드 자유 ✅ + +🎵 This is copyright-free music! +Free for personal and commercial use. +No attribution needed ✅""" + +DEFAULT_TAGS = [ + "반려동물음악", "강아지수면음악", "고양이음악", "펫힐링", + "저작권프리", "수면음악", "분리불안", "반려동물", + "dog music", "cat music", "pet relaxation", "copyright free", + "royalty free", "sleep music", "calming music", "애니밀리", +] + + +def _get_youtube_client(): + """YouTube API 클라이언트 (토큰 자동 리프레시)""" + 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) + print(" [YT] 토큰 리프레시 완료", flush=True) + return build("youtube", "v3", credentials=creds) + + +def upload_video(video_path, title, thumbnail_path=None, extra_tags=None, privacy="public"): + """YouTube 영상 업로드 + + Args: + video_path: 영상 파일 경로 + title: 영상 제목 + thumbnail_path: 썸네일 이미지 경로 + extra_tags: 추가 태그 + privacy: "public", "unlisted", "private" + + Returns: + str: video_id or None + """ + youtube = _get_youtube_client() + + description = DESCRIPTION_TEMPLATE.format(title=title) + tags = list(DEFAULT_TAGS) + if extra_tags: + tags.extend(extra_tags) + + body = { + "snippet": { + "title": title[:100], + "description": description, + "tags": tags[:30], + "categoryId": YOUTUBE_CATEGORY_ID, + "defaultLanguage": "ko", + }, + "status": { + "privacyStatus": privacy, + "madeForKids": False, + "selfDeclaredMadeForKids": False, + }, + } + + print(f" [YT] 업로드 시작: {title}", flush=True) + print(f" [YT] 파일: {video_path} ({os.path.getsize(video_path) / (1024*1024):.0f}MB)", flush=True) + + media = MediaFileUpload( + video_path, + mimetype="video/mp4", + resumable=True, + chunksize=50 * 1024 * 1024, # 50MB chunks + ) + + request = youtube.videos().insert( + part="snippet,status", + body=body, + media_body=media, + ) + + video_id = None + response = None + while response is None: + status, response = request.next_chunk() + if status: + progress = int(status.progress() * 100) + if progress % 20 == 0: + print(f" [YT] 업로드 진행: {progress}%", flush=True) + + video_id = response.get("id") + print(f" [YT] 업로드 완료: https://youtu.be/{video_id}", flush=True) + + # 썸네일 설정 + if thumbnail_path and os.path.exists(thumbnail_path): + try: + # 2MB 제한 체크 + if os.path.getsize(thumbnail_path) > 2 * 1024 * 1024: + from PIL import Image + img = Image.open(thumbnail_path).convert("RGB") + compressed = thumbnail_path.replace(".png", "_thumb.jpg") + img.save(compressed, "JPEG", quality=85) + thumbnail_path = compressed + + youtube.thumbnails().set( + videoId=video_id, + media_body=MediaFileUpload(thumbnail_path, mimetype="image/jpeg"), + ).execute() + print(f" [YT] 썸네일 설정 완료", flush=True) + except Exception as e: + print(f" [YT] 썸네일 실패: {e}", flush=True) + + # 고정 댓글 + if video_id: + try: + youtube.commentThreads().insert( + part="snippet", + body={ + "snippet": { + "videoId": video_id, + "topLevelComment": { + "snippet": {"textOriginal": FIRST_COMMENT} + }, + } + }, + ).execute() + print(f" [YT] 고정 댓글 등록 완료", flush=True) + except Exception as e: + print(f" [YT] 고정 댓글 실패: {e}", flush=True) + + return video_id + + +# 뮤직큐우 재생목록 (기존) +PLAYLIST_ID = "PLr8dPYZT-hCUjL-OgPxJdF81Dvn_g8Vbg" + + +def create_or_get_playlist(title="뮤직큐우"): + """재생목록 ID 반환 (기존 '뮤직큐우' 사용)""" + return PLAYLIST_ID + + +def add_to_playlist(video_id, playlist_id): + """영상을 재생목록에 추가""" + if not playlist_id or not video_id: + return + youtube = _get_youtube_client() + try: + youtube.playlistItems().insert( + part="snippet", + body={ + "snippet": { + "playlistId": playlist_id, + "resourceId": { + "kind": "youtube#video", + "videoId": video_id, + }, + } + }, + ).execute() + print(f" [YT] 재생목록 추가 완료", flush=True) + except Exception as e: + print(f" [YT] 재생목록 추가 실패: {e}", flush=True) + + +if __name__ == "__main__": + print("upload_youtube.py - scheduler.py를 통해 사용하세요")