Add YouTube Uploader functionality with authentication and video upload features
- Implemented YouTubeUploader class for video uploads - Created YouTubeUploaderNode and YouTubeAuthNode for ComfyUI integration - Added configuration management for YouTube API credentials - Included README with installation instructions and features - Updated .gitignore to exclude sensitive files and temporary data - Defined requirements for dependencies in requirements.txt and constraints.txt
This commit is contained in:
+29
-182
@@ -1,194 +1,41 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Sensitive files - OAuth credentials and tokens
|
||||
client_secret.json
|
||||
token.pickle
|
||||
*.pickle
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
import\ os.py
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the enitre vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Cursor
|
||||
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
||||
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
||||
# refer to https://docs.cursor.com/context/ignore-files
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
# Test videos and media
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.jpg
|
||||
*.png
|
||||
test_*
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
[Support me and buy me a coffee :)](coff.ee/chao.k)
|
||||
|
||||
Discamler: AI models have been utilized in creation of this repo.
|
||||
|
||||
# 🎬 YouTube Uploader Nodes for ComfyUI
|
||||
|
||||
Upload videos directly to YouTube from your ComfyUI workflows!
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Direct Upload**: Upload generated videos straight to YouTube
|
||||
- **Custom Thumbnails**: Set custom thumbnails for your videos
|
||||
- **Audio Support**: Include audio in your uploads
|
||||
- **Safety Features**: Upload protection to prevent accidents
|
||||
- **Shorts Optimized**: Perfect for vertical video workflows
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Method 1: Copy to ComfyUI Custom Nodes
|
||||
|
||||
```bash
|
||||
# Copy the entire comfyui_nodes folder to your ComfyUI custom_nodes directory
|
||||
cp -r f:/YouTubeUploader/comfyui_nodes /path/to/ComfyUI/custom_nodes/youtube_uploader
|
||||
```
|
||||
|
||||
### Method 2: Symbolic Link (Recommended for Development)
|
||||
|
||||
```bash
|
||||
# Create symbolic link in ComfyUI custom_nodes
|
||||
ln -s f:/YouTubeUploader/comfyui_nodes /path/to/ComfyUI/custom_nodes/youtube_uploader
|
||||
```
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /path/to/ComfyUI
|
||||
pip install -r custom_nodes/youtube_uploader/requirements_comfyui.txt
|
||||
```
|
||||
|
||||
## 🔧 Setup
|
||||
|
||||
### 1. YouTube API Credentials
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create project and enable YouTube Data API v3
|
||||
3. Create OAuth 2.0 credentials (Desktop application)
|
||||
4. Note your Client ID and Client Secret
|
||||
|
||||
### 2. Node Configuration
|
||||
|
||||
#### YouTube Auth Node
|
||||
- **Input**: Client ID, Client Secret, Authenticate Now
|
||||
- **Output**: Authentication status and channel info
|
||||
- **Usage**: Set up once per session
|
||||
|
||||
#### YouTube Uploader Node
|
||||
- **Video Input**: Connect from any video generation node
|
||||
- **Text Inputs**: Title, description, tags
|
||||
- **Settings**: Privacy level, FPS, upload enabled
|
||||
- **Optional**: Audio track, custom thumbnail
|
||||
|
||||
## 🎯 Workflow Examples
|
||||
|
||||
### Basic Video Upload
|
||||
```
|
||||
[Video Generation] → [YouTube Uploader]
|
||||
↑
|
||||
[YouTube Auth] ──────┘
|
||||
```
|
||||
|
||||
### Advanced with Audio and Thumbnail
|
||||
```
|
||||
[Video Generation] → [YouTube Uploader] ← [Audio Generation]
|
||||
↑ ↑
|
||||
[YouTube Auth] ──────┘ [Thumbnail Generator]
|
||||
```
|
||||
|
||||
## ⚠️ Safety Features
|
||||
|
||||
### Upload Protection
|
||||
- **upload_enabled**: Must be set to `True` to actually upload
|
||||
- **Status Display**: Shows current upload status
|
||||
|
||||
### Testing Mode
|
||||
1. Set `upload_enabled` to `False`
|
||||
2. Test your workflow
|
||||
3. Check video preview
|
||||
4. Enable upload only when ready
|
||||
|
||||
## 🎨 Node Features
|
||||
|
||||
### YouTube Uploader Node
|
||||
- **Smart Video Conversion**: Handles image sequences and single frames
|
||||
- **Audio Integration**: Automatically combines video with audio
|
||||
- **Error Handling**: Graceful failure with detailed messages
|
||||
|
||||
### YouTube Auth Node
|
||||
- **One-Time Setup**: Authenticate once per session
|
||||
- **Channel Info**: Displays connected channel details
|
||||
- **Credential Management**: Secure handling of API keys
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Authentication Failed
|
||||
- Check Client ID and Secret
|
||||
- Ensure YouTube Data API v3 is enabled
|
||||
- Verify OAuth consent screen is configured
|
||||
|
||||
#### Video Upload Failed
|
||||
- Check file size (max 256MB)
|
||||
- Verify video format compatibility
|
||||
- Ensure stable internet connection
|
||||
|
||||
#### Node Not Appearing
|
||||
- Restart ComfyUI after installation
|
||||
- Check console for error messages
|
||||
- Verify all dependencies are installed
|
||||
|
||||
|
||||
## 🎵 Audio Support
|
||||
|
||||
### Supported Formats
|
||||
- WAV, MP3, FLAC (via librosa)
|
||||
- Raw audio tensors from ComfyUI nodes
|
||||
- Auto-conversion to AAC for upload
|
||||
|
||||
### Audio Processing
|
||||
- Automatic sample rate conversion
|
||||
- Volume normalization
|
||||
- Sync with video duration
|
||||
|
||||
## 🖼️ Thumbnail Features
|
||||
|
||||
### Auto-Thumbnails
|
||||
- Uses first frame if no custom thumbnail
|
||||
- Automatic aspect ratio correction
|
||||
- Quality optimization for YouTube
|
||||
|
||||
### Custom Thumbnails
|
||||
- Connect any image generation node
|
||||
- Supports batch processing
|
||||
- Auto-resize to YouTube specs
|
||||
|
||||
## 📊 Analytics Integration
|
||||
|
||||
### Upload Tracking
|
||||
- Video ID capture for analytics
|
||||
- Direct YouTube Studio links
|
||||
- Upload timestamp logging
|
||||
|
||||
### Workflow Metrics
|
||||
- Processing time tracking
|
||||
- Success/failure rates
|
||||
- File size optimization stats
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
### Development Setup
|
||||
1. Fork the repository
|
||||
2. Create feature branch
|
||||
3. Test with multiple ComfyUI versions
|
||||
4. Submit pull request
|
||||
|
||||
### Adding Features
|
||||
- Follow ComfyUI node conventions
|
||||
- Add comprehensive error handling
|
||||
- Include unit tests for core functions
|
||||
- Update documentation
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
- GitHub Issues: Report bugs and feature requests
|
||||
|
||||
---
|
||||
|
||||
**⚠️ Important**: Always test uploads with private videos first. Respect YouTube's Terms of Service and community guidelines.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the current directory to path to help Python find the modules
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.append(current_dir)
|
||||
|
||||
# Now import the node mappings
|
||||
from youtube_uploader_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
# Version info
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "ComfyUI YouTube Extension"
|
||||
__description__ = "Upload videos to YouTube directly from ComfyUI workflows"
|
||||
|
||||
# Display loading message
|
||||
print(f"🎬 YouTube Uploader Nodes v{__version__} loaded!")
|
||||
print("📺 Ready to upload videos to YouTube from ComfyUI")
|
||||
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
class Config:
|
||||
def __init__(self):
|
||||
self.CLIENT_ID = ""
|
||||
self.CLIENT_SECRET = ""
|
||||
self.PROJECT_ID = "comfyui-youtube-uploader"
|
||||
self.CLIENT_SECRETS_FILE = "client_secret.json"
|
||||
|
||||
# Get the directory where this config file is located
|
||||
self.base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
self.client_secrets_path = os.path.join(self.base_dir, self.CLIENT_SECRETS_FILE)
|
||||
|
||||
def create_client_secrets_file(self):
|
||||
"""Create client_secret.json file with provided credentials"""
|
||||
if not self.CLIENT_ID or not self.CLIENT_SECRET:
|
||||
raise ValueError("CLIENT_ID and CLIENT_SECRET must be provided")
|
||||
|
||||
client_secrets = {
|
||||
"installed": {
|
||||
"client_id": self.CLIENT_ID,
|
||||
"project_id": self.PROJECT_ID,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_secret": self.CLIENT_SECRET,
|
||||
"redirect_uris": ["http://localhost"]
|
||||
}
|
||||
}
|
||||
|
||||
with open(self.client_secrets_path, 'w') as f:
|
||||
json.dump(client_secrets, f, indent=2)
|
||||
|
||||
print(f"✅ Created client secrets file: {self.client_secrets_path}")
|
||||
return self.client_secrets_path
|
||||
|
||||
def get_client_secrets_path(self):
|
||||
"""Get the full path to client_secret.json"""
|
||||
return self.client_secrets_path
|
||||
|
||||
def has_client_secrets(self):
|
||||
"""Check if client_secret.json exists"""
|
||||
return os.path.exists(self.client_secrets_path)
|
||||
@@ -0,0 +1,7 @@
|
||||
google-auth==2.23.0
|
||||
google-auth-oauthlib==1.0.0
|
||||
google-auth-httplib2==0.1.0
|
||||
google-api-python-client==2.95.0
|
||||
requests==2.31.0
|
||||
opencv-python>=4.8.0
|
||||
Pillow>=10.0.0
|
||||
@@ -0,0 +1,4 @@
|
||||
google-auth
|
||||
google-auth-oauthlib
|
||||
google-auth-httplib2
|
||||
google-api-python-client
|
||||
@@ -0,0 +1,11 @@
|
||||
# ComfyUI YouTube Uploader Node Requirements
|
||||
opencv-python>=4.8.0
|
||||
scipy>=1.10.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Video processing (optional but recommended)
|
||||
ffmpeg-python>=0.2.0
|
||||
|
||||
# Audio processing (optional)
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.0
|
||||
@@ -0,0 +1,192 @@
|
||||
import os
|
||||
import pickle
|
||||
|
||||
# Check if Google API dependencies are available
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.auth.exceptions import RefreshError
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
GOOGLE_APIS_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
print(f"Google API dependencies not available: {e}")
|
||||
GOOGLE_APIS_AVAILABLE = False
|
||||
|
||||
SCOPES = [
|
||||
'https://www.googleapis.com/auth/youtube.upload',
|
||||
'https://www.googleapis.com/auth/youtube.readonly'
|
||||
]
|
||||
|
||||
if GOOGLE_APIS_AVAILABLE:
|
||||
class YouTubeUploader:
|
||||
def __init__(self):
|
||||
self.service = None
|
||||
self.credentials = None
|
||||
|
||||
def authenticate(self, client_id=None, client_secret=None):
|
||||
"""Authenticate with YouTube API"""
|
||||
creds = None
|
||||
|
||||
# Get the directory where this script is located
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
token_file = os.path.join(script_dir, 'token.pickle')
|
||||
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file, 'rb') as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
if not creds or not creds.valid:
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except RefreshError:
|
||||
creds = None
|
||||
|
||||
if not creds:
|
||||
# Try to use client secrets file first
|
||||
client_secrets_file = os.path.join(script_dir, 'client_secret.json')
|
||||
print(f"🔍 Looking for client secrets file: {client_secrets_file}")
|
||||
if os.path.exists(client_secrets_file):
|
||||
print(f"✅ Found client secrets file: {client_secrets_file}")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(client_secrets_file, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
elif client_id and client_secret:
|
||||
# Fall back to using provided credentials
|
||||
print("🔑 Using provided credentials")
|
||||
client_config = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
}
|
||||
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
|
||||
creds = flow.run_local_server(port=0)
|
||||
else:
|
||||
raise Exception(f"No client secrets file found at {client_secrets_file} and no credentials provided")
|
||||
|
||||
with open(token_file, 'wb') as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
self.credentials = creds
|
||||
self.service = build('youtube', 'v3', credentials=creds)
|
||||
return creds
|
||||
|
||||
def upload_video(self, video_path, title, description, tags, privacy='private'):
|
||||
"""Upload video to YouTube"""
|
||||
if not self.service:
|
||||
raise Exception("Not authenticated. Call authenticate() first.")
|
||||
|
||||
# Handle tags - convert to list if it's a string, or use as-is if it's already a list
|
||||
if isinstance(tags, str):
|
||||
tags_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
||||
elif isinstance(tags, list):
|
||||
tags_list = [str(tag).strip() for tag in tags if str(tag).strip()]
|
||||
else:
|
||||
tags_list = []
|
||||
|
||||
body = {
|
||||
'snippet': {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'tags': tags_list,
|
||||
'categoryId': '22' # People & Blogs
|
||||
},
|
||||
'status': {
|
||||
'privacyStatus': privacy
|
||||
}
|
||||
}
|
||||
|
||||
media = MediaFileUpload(video_path, chunksize=-1, resumable=True)
|
||||
|
||||
try:
|
||||
request = self.service.videos().insert(
|
||||
part=','.join(body.keys()),
|
||||
body=body,
|
||||
media_body=media
|
||||
)
|
||||
|
||||
response = request.execute()
|
||||
video_id = response['id']
|
||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
return video_id, video_url, True
|
||||
|
||||
except HttpError as e:
|
||||
print(f"An HTTP error occurred: {e}")
|
||||
return "", "", False
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return "", "", False
|
||||
|
||||
def _resumable_upload(self, insert_request):
|
||||
"""Handle resumable upload with retry logic"""
|
||||
response = None
|
||||
error = None
|
||||
retry = 0
|
||||
|
||||
while response is None:
|
||||
try:
|
||||
status, response = insert_request.next_chunk()
|
||||
if response is not None:
|
||||
if 'id' in response:
|
||||
return response
|
||||
else:
|
||||
raise Exception(f"Upload failed with unexpected response: {response}")
|
||||
except HttpError as e:
|
||||
if e.resp.status in [500, 502, 503, 504]:
|
||||
error = f"A retriable HTTP error {e.resp.status} occurred:\n{e.content}"
|
||||
retry += 1
|
||||
if retry > 3:
|
||||
break
|
||||
else:
|
||||
raise
|
||||
except Exception as e:
|
||||
error = f"An error occurred: {e}"
|
||||
break
|
||||
|
||||
if error:
|
||||
print(error)
|
||||
return None
|
||||
|
||||
def get_channel_info(self):
|
||||
"""Get information about the authenticated channel"""
|
||||
if not self.service:
|
||||
return None
|
||||
|
||||
try:
|
||||
response = self.service.channels().list(
|
||||
part='snippet,statistics',
|
||||
mine=True
|
||||
).execute()
|
||||
|
||||
if response['items']:
|
||||
channel = response['items'][0]
|
||||
return {
|
||||
'title': channel['snippet']['title'],
|
||||
'subscriber_count': channel['statistics'].get('subscriberCount', 'Hidden'),
|
||||
'video_count': channel['statistics']['videoCount']
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting channel info: {e}")
|
||||
|
||||
return None
|
||||
|
||||
else:
|
||||
# Placeholder class when Google APIs are not available
|
||||
class YouTubeUploader:
|
||||
def __init__(self):
|
||||
self.service = None
|
||||
self.credentials = None
|
||||
|
||||
def authenticate(self, client_id=None, client_secret=None):
|
||||
raise ImportError("Google API dependencies not installed")
|
||||
|
||||
def upload_video(self, video_path, title, description, tags, privacy='private'):
|
||||
raise ImportError("Google API dependencies not installed")
|
||||
|
||||
def get_channel_info(self):
|
||||
raise ImportError("Google API dependencies not installed")
|
||||
@@ -0,0 +1,355 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import cv2
|
||||
import folder_paths
|
||||
|
||||
# Make sure we can import from the same directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if current_dir not in sys.path:
|
||||
sys.path.append(current_dir)
|
||||
|
||||
# Try absolute import
|
||||
try:
|
||||
from youtube_uploader import YouTubeUploader, GOOGLE_APIS_AVAILABLE
|
||||
from config import Config
|
||||
if not GOOGLE_APIS_AVAILABLE:
|
||||
raise ImportError("Google API libraries not installed")
|
||||
DEPENDENCIES_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
print(f"Error: Cannot load YouTube uploader. {e}")
|
||||
print("Please install dependencies with: pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client")
|
||||
YouTubeUploader = None
|
||||
Config = None
|
||||
DEPENDENCIES_AVAILABLE = False
|
||||
|
||||
class YouTubeUploaderNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"video": ("IMAGE",), # ComfyUI video as image sequence
|
||||
"title": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": "My Awesome Short #1"
|
||||
}),
|
||||
"description": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "Check out this amazing video! 🔥\n\n#Shorts #YouTube #ComfyUI #AI"
|
||||
}),
|
||||
"tags": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": "shorts,youtube,ai,comfyui,viral"
|
||||
}),
|
||||
"privacy": (["private", "unlisted", "public"], {
|
||||
"default": "private"
|
||||
}),
|
||||
"fps": ("INT", {
|
||||
"default": 30,
|
||||
"min": 1,
|
||||
"max": 60,
|
||||
"step": 1
|
||||
}),
|
||||
"upload_enabled": ("BOOLEAN", {
|
||||
"default": False
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"audio": ("AUDIO",), # Optional audio input
|
||||
"thumbnail": ("IMAGE",), # Optional custom thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "BOOLEAN")
|
||||
RETURN_NAMES = ("video_id", "upload_url", "success")
|
||||
FUNCTION = "upload_to_youtube"
|
||||
CATEGORY = "YouTube/Upload"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def __init__(self):
|
||||
if not DEPENDENCIES_AVAILABLE:
|
||||
raise RuntimeError("YouTube Uploader module not available. Check youtube_uploader.py file.")
|
||||
if not DEPENDENCIES_AVAILABLE:
|
||||
raise RuntimeError("YouTube Uploader dependencies not installed. Please run: pip install google-auth google-auth-oauthlib google-auth-httplib2 google-api-python-client")
|
||||
self.uploader = YouTubeUploader()
|
||||
self.config = Config()
|
||||
|
||||
def upload_to_youtube(self, video, title, description, tags, privacy, fps, upload_enabled, audio=None, thumbnail=None):
|
||||
"""Upload video to YouTube"""
|
||||
|
||||
if not upload_enabled:
|
||||
print("⚠️ YouTube upload is disabled. Set 'upload_enabled' to True to upload.")
|
||||
return ("", "", False)
|
||||
|
||||
try:
|
||||
# Convert image sequence to video file
|
||||
video_path = self._create_video_from_images(video, fps, audio)
|
||||
# Prepare thumbnail if provided
|
||||
thumbnail_path = None
|
||||
if thumbnail is not None:
|
||||
thumbnail_path = self._save_thumbnail(thumbnail)
|
||||
|
||||
# Authenticate if needed
|
||||
if not self.uploader.service:
|
||||
print("🔐 Authenticating with YouTube...")
|
||||
self.uploader.authenticate()
|
||||
# Upload video
|
||||
print(f"🚀 Uploading video: {title}")
|
||||
video_id = self.uploader.upload_video(
|
||||
video_path, title, description, tags, privacy
|
||||
)
|
||||
|
||||
if video_id:
|
||||
upload_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
print(f"✅ Upload successful! Video ID: {video_id}")
|
||||
print(f"🔗 Video URL: {upload_url}")
|
||||
|
||||
# Set thumbnail if provided
|
||||
if thumbnail_path and video_id:
|
||||
self._set_video_thumbnail(video_id, thumbnail_path)
|
||||
|
||||
# Cleanup temporary files
|
||||
self._cleanup_temp_files(video_path, thumbnail_path)
|
||||
|
||||
return (video_id, upload_url, True)
|
||||
else:
|
||||
print("❌ Upload failed")
|
||||
return ("", "", False)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error uploading to YouTube: {str(e)}")
|
||||
return ("", "", False)
|
||||
|
||||
def _create_video_from_images(self, images, fps, audio=None):
|
||||
"""Convert ComfyUI image sequence to video file"""
|
||||
if len(images.shape) == 3:
|
||||
# Single image, create 3-second video
|
||||
images = images.unsqueeze(0).repeat(fps * 3, 1, 1, 1)
|
||||
|
||||
# Convert tensor to numpy
|
||||
if isinstance(images, torch.Tensor):
|
||||
images_np = images.cpu().numpy()
|
||||
else:
|
||||
images_np = images
|
||||
|
||||
# Ensure values are in 0-255 range
|
||||
if images_np.max() <= 1.0:
|
||||
images_np = (images_np * 255).astype(np.uint8)
|
||||
else:
|
||||
images_np = images_np.astype(np.uint8)
|
||||
|
||||
# Create temporary video file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
video_path = os.path.join(temp_dir, f"comfyui_youtube_upload_{hash(str(images_np.shape))}.mp4")
|
||||
|
||||
# Get video dimensions
|
||||
height, width = images_np.shape[1:3]
|
||||
|
||||
# Ensure dimensions are even (required for some codecs)
|
||||
if width % 2 != 0:
|
||||
width -= 1
|
||||
if height % 2 != 0:
|
||||
height -= 1
|
||||
|
||||
# Create video writer
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
out = cv2.VideoWriter(video_path, fourcc, fps, (width, height))
|
||||
|
||||
try:
|
||||
for i in range(len(images_np)):
|
||||
frame = images_np[i]
|
||||
|
||||
# Resize if needed
|
||||
if frame.shape[1] != width or frame.shape[0] != height:
|
||||
frame = cv2.resize(frame, (width, height))
|
||||
|
||||
# Convert RGB to BGR for OpenCV
|
||||
if len(frame.shape) == 3 and frame.shape[2] == 3:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
||||
|
||||
out.write(frame)
|
||||
|
||||
out.release()
|
||||
|
||||
# Add audio if provided
|
||||
if audio is not None:
|
||||
video_path = self._add_audio_to_video(video_path, audio)
|
||||
|
||||
print(f"📹 Created video: {video_path}")
|
||||
return video_path
|
||||
|
||||
except Exception as e:
|
||||
out.release()
|
||||
print(f"❌ Error creating video: {str(e)}")
|
||||
raise
|
||||
|
||||
def _add_audio_to_video(self, video_path, audio):
|
||||
"""Add audio to video using ffmpeg"""
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
# Save audio to temporary file
|
||||
audio_path = video_path.replace('.mp4', '_audio.wav')
|
||||
|
||||
# Convert audio tensor to wav file
|
||||
if isinstance(audio, torch.Tensor):
|
||||
audio_np = audio.cpu().numpy()
|
||||
else:
|
||||
audio_np = audio
|
||||
|
||||
# Save audio using scipy or librosa if available
|
||||
try:
|
||||
import scipy.io.wavfile as wavfile
|
||||
# Ensure audio is in correct format
|
||||
if audio_np.max() <= 1.0:
|
||||
audio_np = (audio_np * 32767).astype(np.int16)
|
||||
wavfile.write(audio_path, 44100, audio_np)
|
||||
except ImportError:
|
||||
print("⚠️ scipy not available, skipping audio")
|
||||
return video_path
|
||||
|
||||
# Combine video and audio
|
||||
output_path = video_path.replace('.mp4', '_with_audio.mp4')
|
||||
cmd = [
|
||||
'ffmpeg', '-y',
|
||||
'-i', video_path,
|
||||
'-i', audio_path,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-shortest',
|
||||
output_path
|
||||
]
|
||||
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
# Cleanup
|
||||
os.remove(video_path)
|
||||
os.remove(audio_path)
|
||||
|
||||
return output_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not add audio: {str(e)}")
|
||||
return video_path
|
||||
|
||||
def _save_thumbnail(self, thumbnail):
|
||||
"""Save thumbnail image to temporary file"""
|
||||
if isinstance(thumbnail, torch.Tensor):
|
||||
thumbnail_np = thumbnail.cpu().numpy()
|
||||
else:
|
||||
thumbnail_np = thumbnail
|
||||
|
||||
# Convert to PIL Image
|
||||
if thumbnail_np.max() <= 1.0:
|
||||
thumbnail_np = (thumbnail_np * 255).astype(np.uint8)
|
||||
|
||||
if len(thumbnail_np.shape) == 4:
|
||||
thumbnail_np = thumbnail_np[0] # Take first image if batch
|
||||
|
||||
img = Image.fromarray(thumbnail_np)
|
||||
|
||||
# Save to temporary file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
thumbnail_path = os.path.join(temp_dir, f"comfyui_thumbnail_{hash(str(thumbnail_np.shape))}.jpg")
|
||||
img.save(thumbnail_path, 'JPEG', quality=95)
|
||||
|
||||
return thumbnail_path
|
||||
|
||||
def _set_video_thumbnail(self, video_id, thumbnail_path):
|
||||
"""Set custom thumbnail for uploaded video"""
|
||||
try:
|
||||
# This requires additional YouTube API permissions
|
||||
self.uploader.youtube.thumbnails().set(
|
||||
videoId=video_id,
|
||||
media_body=thumbnail_path
|
||||
).execute()
|
||||
print("🖼️ Custom thumbnail set successfully")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not set thumbnail: {str(e)}")
|
||||
|
||||
def _cleanup_temp_files(self, *file_paths):
|
||||
"""Clean up temporary files"""
|
||||
for file_path in file_paths:
|
||||
if file_path and os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
class YouTubeAuthNode:
|
||||
"""Separate node for YouTube authentication"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"client_id": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": ""
|
||||
}),
|
||||
"client_secret": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": ""
|
||||
}),
|
||||
"authenticate_now": ("BOOLEAN", {
|
||||
"default": False
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BOOLEAN", "STRING")
|
||||
RETURN_NAMES = ("authenticated", "channel_info")
|
||||
FUNCTION = "authenticate_youtube"
|
||||
CATEGORY = "YouTube/Auth"
|
||||
|
||||
def authenticate_youtube(self, client_id, client_secret, authenticate_now):
|
||||
"""Authenticate with YouTube API"""
|
||||
|
||||
if not authenticate_now:
|
||||
return (False, "Authentication not requested")
|
||||
|
||||
try:
|
||||
# Create uploader instance
|
||||
uploader = YouTubeUploader()
|
||||
|
||||
# Create client secrets if provided
|
||||
if client_id and client_secret:
|
||||
config = Config()
|
||||
config.CLIENT_ID = client_id
|
||||
config.CLIENT_SECRET = client_secret
|
||||
config.PROJECT_ID = "comfyui-youtube-uploader"
|
||||
config.create_client_secrets_file()
|
||||
|
||||
# Authenticate
|
||||
uploader.authenticate()
|
||||
|
||||
# Get channel info
|
||||
channel_info = uploader.get_channel_info()
|
||||
if channel_info:
|
||||
info_text = f"Channel: {channel_info['title']}\nSubscribers: {channel_info['subscriber_count']}\nVideos: {channel_info['video_count']}"
|
||||
else:
|
||||
info_text = "Authentication successful"
|
||||
|
||||
print(f"✅ YouTube authentication successful!")
|
||||
print(f"📺 {info_text}")
|
||||
|
||||
return (True, info_text)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Authentication failed: {str(e)}"
|
||||
print(f"❌ {error_msg}")
|
||||
return (False, error_msg)
|
||||
|
||||
# Node mappings for ComfyUI
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"YouTubeUploaderNode": YouTubeUploaderNode,
|
||||
"YouTubeAuthNode": YouTubeAuthNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"YouTubeUploaderNode": "🎬 YouTube Uploader",
|
||||
"YouTubeAuthNode": "🔐 YouTube Auth"
|
||||
}
|
||||
Reference in New Issue
Block a user