diff --git a/RH_VideoUploader.py b/RH_VideoUploader.py new file mode 100644 index 0000000..ab9f6c9 --- /dev/null +++ b/RH_VideoUploader.py @@ -0,0 +1,186 @@ +import requests +import json +import os +import time + +# Try importing folder_paths safely for potential future use, though not strictly needed for this node's core logic +try: + import folder_paths + comfyui_env_available = True +except ImportError: + folder_paths = None # Set to None if not available + comfyui_env_available = False + print("RH_VideoUploader: ComfyUI folder_paths not found. Cannot determine input file path.") + + +class RH_VideoUploader: + # This node relies heavily on its JavaScript counterpart for the actual upload. + # The Python part primarily retrieves the filename returned by the JS upload process. + + @classmethod + def INPUT_TYPES(cls): + # Add a required input for the filename provided by the JS widget after ComfyUI upload + # This widget type should allow JS to set its value. + # A standard STRING input where JS sets the value seems appropriate. + return { + "required": { + "apiConfig": ("STRUCT",), + # This input receives the filename assigned by ComfyUI's upload endpoint + # It's made visible and editable, but primarily set by the JS interaction. + "video": ("STRING", {"default": "", "multiline": False}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("filename",) + FUNCTION = "upload_and_get_filename" # Renamed function to reflect action + CATEGORY = "RunningHub" # Or your preferred category + OUTPUT_NODE = False # This node outputs data, not a final image/video display + + def upload_and_get_filename(self, apiConfig, video): + """ + Reads the video file from ComfyUI's input directory based on the 'video' filename, + uploads it to the RunningHub API, and returns the resulting filename/ID. + """ + # 1. Validate inputs + if not comfyui_env_available or not folder_paths: + raise ImportError("folder_paths module is required for RH_VideoUploader to find input files.") + + if not isinstance(apiConfig, dict) or not apiConfig.get("apiKey") or not apiConfig.get("base_url"): + raise ValueError("Invalid or missing apiConfig structure provided to RH_VideoUploader.") + + if not video or video.strip() == "": + raise ValueError("No video filename provided. Please select and upload a video using the node's widget.") + + apiKey = apiConfig["apiKey"] + baseUrl = apiConfig["base_url"] + + # 2. Get the full path to the uploaded file in ComfyUI's input directory + # The 'video' input contains the relative path within the input directory + try: + video_path = folder_paths.get_annotated_filepath(video) + if not video_path or not os.path.exists(video_path): + # Check subdirectories common for uploads if get_annotated_filepath fails directly + # (get_annotated_filepath usually handles this, but as a fallback) + potential_path = os.path.join(folder_paths.get_input_directory(), video) + if os.path.exists(potential_path): + video_path = potential_path + else: # Check common subfolders like 'uploads' + potential_path = os.path.join(folder_paths.get_input_directory(), 'uploads', video) + if os.path.exists(potential_path): + video_path = potential_path + else: + raise FileNotFoundError(f"Video file not found in input directory: {video}") + + print(f"RH_VideoUploader: Found video file at: {video_path}") + + except Exception as e: + raise FileNotFoundError(f"Error finding video file '{video}': {e}") + + # 3. Prepare for RunningHub API upload + # *** Use the same endpoint and data structure as ImageUploader *** + upload_api_url = f"{baseUrl}/task/openapi/upload" # Corrected endpoint + # Headers should likely NOT contain the apiKey based on ImageUploader + headers = { + # 'X-API-Key': apiKey, # REMOVED based on ImageUploader + 'User-Agent': 'ComfyUI-RH_VideoUploader/1.0' + } + # Data payload containing apiKey and fileType + data = { + 'apiKey': apiKey, + 'fileType': 'video' # Added fileType, assuming 'video' is correct + } + + # 4. Read the file and upload + print(f"RH_VideoUploader: Uploading {video_path} to {upload_api_url}...") + + # Add retry logic + max_retries = 5 + retry_delay = 1 # Initial delay in seconds + last_exception = None + response = None + + for attempt in range(max_retries): + try: + with open(video_path, 'rb') as f: + files = { + 'file': (os.path.basename(video_path), f) + } + # Send request with apiKey only in data, files attached + response = requests.post(upload_api_url, headers=headers, data=data, files=files) + print(f"RH_VideoUploader: Upload attempt {attempt + 1}/{max_retries} - Status Code: {response.status_code}") + response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + + # If successful, break the loop + break + + except requests.exceptions.Timeout as e: + last_exception = e + print(f"RH_VideoUploader: Upload attempt {attempt + 1} timed out.") + except requests.exceptions.ConnectionError as e: + last_exception = e + print(f"RH_VideoUploader: Upload attempt {attempt + 1} connection error: {e}") + except requests.exceptions.RequestException as e: + last_exception = e + print(f"RH_VideoUploader: Upload attempt {attempt + 1} failed: {e}") + # Check if response exists for potential non-2xx status codes handled by raise_for_status + if e.response is not None: + print(f"RH_VideoUploader: Response content on error: {e.response.text}") + # Potentially break early for client errors (4xx) that won't be fixed by retrying? + # For now, we retry all RequestExceptions + + # Wait before retrying, unless it's the last attempt + if attempt < max_retries - 1: + print(f"RH_VideoUploader: Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + else: + print(f"RH_VideoUploader: Max retries ({max_retries}) reached.") + # Raise the last exception encountered after all retries fail + raise ConnectionError(f"Failed to upload video to RunningHub API after {max_retries} attempts. Last error: {last_exception}") from last_exception + + # If loop completes without breaking (shouldn't happen if success breaks), or response is None + if response is None: + raise ConnectionError(f"Upload failed after {max_retries} attempts, no response received. Last error: {last_exception}") + + # 5. Parse successful response (outside the retry loop) + try: + response_json = response.json() + print(f"RH_VideoUploader: Upload API Response JSON: {response_json}") + except json.JSONDecodeError as e: + print(f"RH_VideoUploader: Failed to decode JSON response: {response.text}") + raise ValueError(f"Failed to decode API response after successful upload: {e}") from e + + # Check API-level success code + if response_json.get('code') != 0: + raise ValueError(f"RunningHub API reported an error after upload: {response_json.get('msg', 'Unknown API error')}") + + # Extract filename using the correct key 'fileName' + rh_data = response_json.get("data", {}) + uploaded_filename = None # Initialize + if isinstance(rh_data, dict): + uploaded_filename = rh_data.get("fileName") # Corrected key: fileName + # Add fallbacks if needed, though fileName seems primary based on logs + # uploaded_filename = uploaded_filename or rh_data.get("fileId") or rh_data.get("url") + elif isinstance(rh_data, str): + uploaded_filename = rh_data + + if not isinstance(uploaded_filename, str) or not uploaded_filename: + raise ValueError("Upload succeeded but 'fileName' (or compatible field) not found in RunningHub API response.data.") + + print(f"RH_VideoUploader: Upload successful. RunningHub filename/ID: {uploaded_filename}") + return (uploaded_filename,) + + # Removed generic Exception catch block to let specific errors propagate + # except Exception as e: + # print(f"RH_VideoUploader: An unexpected error occurred during upload: {e}") + # raise RuntimeError(f"Unexpected error during video upload: {e}") from e + +# Mappings for ComfyUI +# Moved to __init__.py typically, but included here for completeness if run standalone initially +# NODE_CLASS_MAPPINGS = { +# "VideoUploaderNode_RH": VideoUploaderNode +# } +# NODE_DISPLAY_NAME_MAPPINGS = { +# "VideoUploaderNode_RH": "RunningHub Video Uploader" +# } \ No newline at end of file diff --git a/__init__.py b/__init__.py index a1c5b60..a143d34 100644 --- a/__init__.py +++ b/__init__.py @@ -2,6 +2,8 @@ from .RH_SettingsNode import SettingsNode from .RH_NodeInfoListNode import NodeInfoListNode from .RH_ExecuteNode import ExecuteNode from .RH_ImageUploaderNode import ImageUploaderNode +from .RH_VideoUploader import RH_VideoUploader + from .RH_Utils import * @@ -14,6 +16,8 @@ NODE_CLASS_MAPPINGS = { "RH_Utils": AnyToStringNode, "RH_ExtractImage": RH_Extract_Image_From_List, "RH_BatchImages": RH_Batch_Images_From_List, + "RH_VideoUploader": RH_VideoUploader, + } @@ -25,7 +29,13 @@ NODE_DISPLAY_NAME_MAPPINGS = { "RH_Utils": "RH Anything to String", "RH_ExtractImage": "RH Extract Image From ImageList", "RH_BatchImages": "RH Batch Images From ImageList", + "RH_VideoUploader": "RH Video Uploader", + } +# Web Directory Setup +# Tells ComfyUI where to find the JavaScript files associated with nodes in this package +WEB_DIRECTORY = "./web/js" -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS",] + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/web/js/videoUploaderNode.js b/web/js/videoUploaderNode.js new file mode 100644 index 0000000..a2a2c41 --- /dev/null +++ b/web/js/videoUploaderNode.js @@ -0,0 +1,156 @@ +import { app } from "../../../scripts/app.js"; +import { api } from '../../../scripts/api.js'; + +// Basic helper for creating elements +function createElement(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + Object.assign(el, attrs); + children.forEach(child => el.appendChild(typeof child === 'string' ? document.createTextNode(child) : child)); + return el; +} + +app.registerExtension({ + name: "RunningHub.VideoUploader", + + nodeCreated(node) { + if (node.comfyClass !== "RH_VideoUploader") { + return; + } + + // Find the regular STRING widget that will hold the ComfyUI filename + const filenameWidget = node.widgets.find((w) => w.name === "video"); + if (!filenameWidget) { + console.error("RH_VideoUploader: Could not find 'video' widget on node:", node.id); + // Might need adjustment if widget name differs or isn't created by default + return; + } + // Make the default widget visually hidden initially if desired, or style it smaller. + // filenameWidget.inputEl.style.display = 'none'; // Example: hide the input box + + // --- Create Custom UI Elements (Button, Preview, Status) --- + const container = document.createElement("div"); + container.style.margin = "5px 0"; + + const uploadButton = createElement("button", { + textContent: "Select Video", // Changed text + style: "width: 100%; margin-bottom: 5px;" + }); + + const previewArea = createElement("div", { + style: "width: 100%; max-width: 256px; aspect-ratio: 16/9; background: #333; border-radius: 4px; margin-bottom: 5px; overflow: hidden; display: none; position: relative; margin-left: auto; margin-right: auto;" + }); + const previewVideo = createElement("video", { + controls: true, + style: "width: 100%; height: 100%; display: block;" + }); + const statusText = createElement("p", { + textContent: "No video selected.", // Updated initial text + style: "margin: 0; padding: 2px 5px; font-size: 0.8em; color: #ccc; text-align: center;" + }); + + previewArea.appendChild(previewVideo); + container.appendChild(uploadButton); + container.appendChild(previewArea); + container.appendChild(statusText); + + // Add the custom UI below the standard widgets + node.addDOMWidget("video_uploader_widget", "preview", container); + + // Function to upload file to ComfyUI backend + async function uploadFileToComfyUI(file) { + statusText.textContent = `Uploading ${file.name} to ComfyUI...`; + uploadButton.disabled = true; + uploadButton.textContent = "Uploading..."; + filenameWidget.value = ""; // Clear previous filename + + try { + // Create FormData object + const body = new FormData(); + // IMPORTANT: Use 'image' as the key, ComfyUI's endpoint expects this even for videos + body.append('image', file); + // Set overwrite=true if you want to replace existing files with the same name + // body.append('overwrite', 'true'); + + // Use ComfyUI's api.fetchApi for the upload + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body, + }); + + if (resp.status === 200 || resp.status === 201) { + const data = await resp.json(); + // data should contain { name: string, subfolder: string, type: string (input/temp) } + if (data.name) { + // We typically only need the filename, maybe prepend subfolder if needed by Python logic + const comfyFilename = data.subfolder ? `${data.subfolder}/${data.name}` : data.name; + filenameWidget.value = comfyFilename; // Set the Python node's input widget + statusText.textContent = `Video ready: ${comfyFilename}`; + uploadButton.textContent = "Video Selected"; + console.log(`RH_VideoUploader: ComfyUI upload successful: ${comfyFilename}`); + } else { + throw new Error("Filename not found in ComfyUI upload response."); + } + } else { + throw new Error(`ComfyUI upload failed: ${resp.status} ${resp.statusText}`); + } + } catch (error) { + console.error("RH_VideoUploader: ComfyUI upload error:", error); + statusText.textContent = `Error uploading to ComfyUI: ${error.message}`; + filenameWidget.value = "ERROR"; // Indicate error in the widget + } finally { + uploadButton.disabled = false; // Re-enable button + // Keep text as "Video Selected" or revert? + if (filenameWidget.value && filenameWidget.value !== "ERROR") { + uploadButton.textContent = "Select Another Video"; + } else { + uploadButton.textContent = "Select Video"; + } + } + } + + // --- Event Listener for Button --- + uploadButton.addEventListener("click", () => { + const fileInput = createElement("input", { + type: "file", + accept: "video/mp4,video/webm,video/ogg,video/quicktime,video/x-matroska,video/*", + style: "display: none;" + }); + + fileInput.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (file) { + // Show preview + try { + const objectURL = URL.createObjectURL(file); + previewVideo.src = objectURL; + if (previewVideo.dataset.objectUrl) { + URL.revokeObjectURL(previewVideo.dataset.objectUrl); + } + previewVideo.dataset.objectUrl = objectURL; + previewArea.style.display = "block"; + } catch (e) { + console.error("Error creating object URL for preview:", e); + previewArea.style.display = "none"; + statusText.textContent = "Preview failed. Ready to upload."; // Update status if preview fails + } + + // Upload the file to ComfyUI backend + uploadFileToComfyUI(file); + } + fileInput.remove(); + }); + + document.body.appendChild(fileInput); + fileInput.click(); + }); + + // Clean up object URL when node is removed + const onRemoved = node.onRemoved; + node.onRemoved = () => { + if (previewVideo.dataset.objectUrl) { + URL.revokeObjectURL(previewVideo.dataset.objectUrl); + } + onRemoved?.(); + }; + }, +}); \ No newline at end of file