From 010f4826959b6b93caecb72ea1f919ed6c679d7b Mon Sep 17 00:00:00 2001 From: Chao Date: Tue, 10 Jun 2025 23:47:08 +0200 Subject: [PATCH] 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 --- .gitignore | 211 ++++------------------- README_COMFYUI.md | 178 ++++++++++++++++++++ __init__.py | 21 +++ config.py | 44 +++++ constraints.txt | 7 + requirements.txt | 4 + requirements_comfyui.txt | 11 ++ youtube_uploader.py | 192 +++++++++++++++++++++ youtube_uploader_node.py | 355 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 841 insertions(+), 182 deletions(-) create mode 100644 README_COMFYUI.md create mode 100644 __init__.py create mode 100644 config.py create mode 100644 constraints.txt create mode 100644 requirements.txt create mode 100644 requirements_comfyui.txt create mode 100644 youtube_uploader.py create mode 100644 youtube_uploader_node.py diff --git a/.gitignore b/.gitignore index 7b004e5..94b2237 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +# Test videos and media +*.mp4 +*.avi +*.mov +*.jpg +*.png +test_* diff --git a/README_COMFYUI.md b/README_COMFYUI.md new file mode 100644 index 0000000..4783de9 --- /dev/null +++ b/README_COMFYUI.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..774ea27 --- /dev/null +++ b/__init__.py @@ -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") diff --git a/config.py b/config.py new file mode 100644 index 0000000..3fb8788 --- /dev/null +++ b/config.py @@ -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) diff --git a/constraints.txt b/constraints.txt new file mode 100644 index 0000000..6d336c9 --- /dev/null +++ b/constraints.txt @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c68661 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +google-auth +google-auth-oauthlib +google-auth-httplib2 +google-api-python-client diff --git a/requirements_comfyui.txt b/requirements_comfyui.txt new file mode 100644 index 0000000..e8fe06b --- /dev/null +++ b/requirements_comfyui.txt @@ -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 diff --git a/youtube_uploader.py b/youtube_uploader.py new file mode 100644 index 0000000..7138754 --- /dev/null +++ b/youtube_uploader.py @@ -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") \ No newline at end of file diff --git a/youtube_uploader_node.py b/youtube_uploader_node.py new file mode 100644 index 0000000..9645789 --- /dev/null +++ b/youtube_uploader_node.py @@ -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" +}