commit e3e2091e5aaeb85a7fcc2886b6db8287dd6dda3c Author: misterjoessef <107667789+misterjoessef@users.noreply.github.com> Date: Tue Aug 6 14:23:10 2024 -0700 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40def25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__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 + +# 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/#use-with-ide +.pdm.toml + +# 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/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# 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/ +.vscode/settings.json diff --git a/Common/Utils.py b/Common/Utils.py new file mode 100644 index 0000000..c0c67ac --- /dev/null +++ b/Common/Utils.py @@ -0,0 +1,296 @@ +import json +import folder_paths +import os +import requests +import base64 +import traceback +import mimetypes +from PIL import Image, ImageSequence, ImageOps +import numpy as np +import imghdr +import mimetypes +import subprocess +import torch +import node_helpers +from matplotlib import font_manager + +chunk_size = 5 * 1024 * 1024 # 1MB chunks + + +def get_system_font_files(): + font_files = [] + for font in font_manager.fontManager.ttflist: + font_file = os.path.basename(font.fname) + font_files.append(font_file) + return font_files + + +def is_image(file_path): + # Check if it's a common image type + if imghdr.what(file_path) is not None: + return True + # Additional check for SVG files + mime_type, _ = mimetypes.guess_type(file_path) + return mime_type is not None and mime_type.startswith("image") + + +def is_video(file_path): + video_extensions = [".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv"] + _, extension = os.path.splitext(file_path.lower()) + mime_type, _ = mimetypes.guess_type(file_path) + return extension in video_extensions or ( + mime_type is not None and mime_type.startswith("video") + ) + + +def is_gif(file_path): + return imghdr.what(file_path) == "gif" + + +def get_video_duration(file_path): + if not is_video(file_path): + return None + + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + file_path, + ], + capture_output=True, + text=True, + ) + + data = json.loads(result.stdout) + duration = float(data["format"]["duration"]) + return duration + except (subprocess.SubprocessError, KeyError, json.JSONDecodeError): + return None + + +def images_file_to_tensor(image): + image_path = folder_paths.get_annotated_filepath(image) + img = node_helpers.pillow(Image.open, image_path) + return images_data_to_tensor(img) + + +def images_data_to_tensor(img): + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ["MPO"] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == "I": + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if "A" in i.getbands(): + mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0 + mask = 1.0 - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return (output_image, output_mask) + + +def images_tensor_to_file(images, output_dir, compress_level, extension="png"): + filename_prefix = "socialman" + full_output_folder, filename, counter, subfolder, filename_prefix = ( + folder_paths.get_save_image_path( + filename_prefix, output_dir, images[0].shape[1], images[0].shape[0] + ) + ) + results = list() + for batch_number, image in enumerate(images): + i = 255.0 * image.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}_.{extension}" + img.save( + os.path.join(full_output_folder, file), + compress_level=compress_level, + ) + results.append(f"{folder_paths.get_output_directory()}/{file}") + counter += 1 + return results + + +def get_file_base64(file_path): + with open(file_path, "rb") as file: + content = file.read() + file_content_base64 = base64.b64encode(content).decode("utf-8") + return file_content_base64 + + +def upload_file_to_signed_s3(file_path, presigned_url): + # Check if file exists + if not os.path.isfile(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + content_type, _ = mimetypes.guess_type(file_path) + if content_type is None: + content_type = "application/octet-stream" + # Get file size + file_size = os.path.getsize(file_path) + + # Open file in binary mode + with open(file_path, "rb") as file: + # Use requests to PUT the file to the pre-signed URL + response = requests.put( + presigned_url, + data=file, + headers={"Content-Length": str(file_size), "Content-Type": content_type}, + ) + + # Check if the upload was successful + if response.status_code == 200: + print(f"File {file_path} uploaded successfully.") + else: + print(f"Failed to upload file. Status code: {response.status_code}") + print(f"Response: {response.text}") + + +def upload_file(file_path, api_base_url, auth_token): + try: + # Initiate upload + headers = {"Authorization": auth_token} + total_chunks = calculate_total_chunks(file_path) + + print(f"Initiating upload for file: {file_path}") + init_response = requests.post( + f"{api_base_url}/initiate-upload", + headers=headers, + json={ + "fileName": os.path.basename(file_path), + "totalChunks": total_chunks, + }, + ) + init_response.raise_for_status() + print(f"Initiation response: {init_response.text}") + + upload_id = init_response.json()["uploadId"] + print(f"Upload ID: {upload_id}") + + # Read file in chunks and upload + + chunk_number = 1 + + with open(file_path, "rb") as f: + while chunk_number <= total_chunks: + chunk = f.read(chunk_size) + if not chunk: + break + + upload_url = f"{api_base_url}/upload-chunk/{upload_id}/{chunk_number}" + print(f"upload_url: {upload_url}") + print(f"Upload ID: {upload_id}") + print(f"Uploading chunk {chunk_number}/{total_chunks}") + response = requests.put( + upload_url, headers=headers, files={"file": chunk} + ) + # response.raise_for_status() + print(f"Chunk {chunk_number} upload response: {response.text}") + + chunk_number += 1 + + # Complete upload + complete_url = f"{api_base_url}/complete-upload/{upload_id}" + print("Completing upload") + complete_response = requests.post(complete_url, headers=headers) + complete_response.raise_for_status() + print(f"Complete upload response: {complete_response.text}") + + return upload_id + + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + print( + f"Response content: {e.response.content if e.response else 'No response'}" + ) + print(f"Traceback: {traceback.format_exc()}") + raise + + except Exception as e: + print(f"Unexpected error: {e}") + print(f"Traceback: {traceback.format_exc()}") + raise + + +def calculate_total_chunks(file_path): + file_size = os.path.getsize(file_path) + return -(-file_size // chunk_size) # Ceiling division + + +def image_files_only(): + image_extensions = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp") + input_dir = folder_paths.get_input_directory() + return [ + f + for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) + and f.lower().endswith(image_extensions) + ] + + +def mask_string(input_string): + # Ensure the input is a string + input_string = str(input_string) + + # If the string is 5 characters or longer + if len(input_string) >= 5: + return "***" + input_string[-5:] + # If the string is shorter than 5 characters + else: + return "***" + input_string + + +def write_json_to_file(filename, data): + with open(filename, "w") as file: + json.dump(data, file, indent=4) + + +def read_json_from_file(filename): + try: + with open(filename, "r") as file: + return json.load(file) + except FileNotFoundError: + return {"error": "File not found."} + except json.JSONDecodeError: + return {"error": "Invalid JSON in file."} + + +def update_json_file(filename, new_data): + old_data = read_json_from_file(filename) + old_data.update(new_data) + with open(filename, "w") as file: + json.dump(old_data, file, indent=4) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09cd78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cc3c6c --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# MLTask-ComfyUI + diff --git a/SocialMan/PosterData.py b/SocialMan/PosterData.py new file mode 100644 index 0000000..7335511 --- /dev/null +++ b/SocialMan/PosterData.py @@ -0,0 +1,550 @@ +from ..Common.Utils import read_json_from_file, image_files_only, images_tensor_to_file +import folder_paths +import os +from ..constants import SOCIAL_MAN_KEYS_FILE + + +def get_account_id(network, account_name, social_data, account_key, account_id_key): + for account in social_data.get(network, []): + if account.get(account_key) == account_name: + return account.get(account_id_key) + return None + + +class SocialManMediaToPoster: + def __init__(self): + self.type = "output" + self.output_dir = folder_paths.get_output_directory() + self.compress_level = 4 + + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [ + f + for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) + ] + return { + "optional": { + "media_file": (sorted(files),), + "images": ("IMAGE",), + "video_combine_filenames": ("VHS_FILENAMES",), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("image_path",) + + def pass_data( + self, + media_file=None, + images=None, + video_combine_filenames=None, + ): + + if video_combine_filenames is not None and images is not None: + raise Exception( + "Only images or video_combine_filenames should be connected but not both" + ) + + if video_combine_filenames is None and images is None: + return ([f"{folder_paths.get_input_directory()}/{media_file}"],) + + if images is not None: + return ( + images_tensor_to_file( + images, self.output_dir, self.compress_level, "jpeg" + ), + ) + # print("-=---") + save_output, output_files = video_combine_filenames + # print(output_files[-1]) + # print("-=---") + return ([output_files[-1]],) + + +class SocialManPostData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "optional": { + "title": ( + "STRING", + { + "multiline": True, + "default": "post title", + }, + ), + "description": ( + "STRING", + { + "multiline": True, + "default": "post description", + }, + ), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_POST_DATA",) + RETURN_NAMES = ("social_man_data",) + + def pass_data(self, title, description): + return ({"title": title, "description": description},) + + +class TiktokPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "optional": { + "caption": ( + "STRING", + { + "multiline": True, + "default": "tiktok caption", + }, + ), + "photo_title": ( + "STRING", + { + "multiline": True, + "default": "Title (for photo posts only)", + }, + ), + "video_cover_timestamp_percent_from_0_to_1": ( + "FLOAT", + { + "default": 0, + }, + ), + "privacy": ( + [ + "FOLLOWER_OF_CREATOR", + "MUTUAL_FOLLOW_FRIENDS", + "PUBLIC_TO_EVERYONE", + "SELF_ONLY", + ], + { + "default": "PUBLIC_TO_EVERYONE", + }, + ), + "users_can_comment": ("BOOLEAN", {"default": True}), + "users_can_duet": ("BOOLEAN", {"default": True}), + "users_can_stitch": ("BOOLEAN", {"default": True}), + "content_disclosure_enabled": ("BOOLEAN", {"default": False}), + "content_disclosure_branded_content": ("BOOLEAN", {"default": False}), + "content_disclosure_your_brand": ("BOOLEAN", {"default": False}), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_TIKTOK_DATA",) + RETURN_NAMES = ("tiktok_data",) + + def pass_data( + self, + caption, + photo_title, + video_cover_timestamp_percent_from_0_to_1, + privacy, + users_can_comment, + users_can_duet, + users_can_stitch, + content_disclosure_enabled, + content_disclosure_branded_content, + content_disclosure_your_brand, + ): + if not 0 <= video_cover_timestamp_percent_from_0_to_1 <= 1: + raise ValueError( + "video_cover_timestamp_percent_from_0_to_1 must be between 0 and 1 inclusive" + ) + return ( + { + "caption": caption, + "photo_title": photo_title, + "video_cover_timestamp_percent_from_0_to_1": video_cover_timestamp_percent_from_0_to_1, + "privacy": privacy, + "users_can_comment": users_can_comment, + "users_can_duet": users_can_duet, + "users_can_stitch": users_can_stitch, + "content_disclosure_enabled": content_disclosure_enabled, + "content_disclosure_branded_content": content_disclosure_branded_content, + "content_disclosure_your_brand": content_disclosure_your_brand, + }, + ) + + +class YoutubePosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + channels = [ + channel.get("channel_name") + for channel in social_man_data.get("youtube", []) + ] + image_files = image_files_only() + + return { + "required": { + "target_channel": ( + channels, + { + "default": ( + channels[0] if channels else "Refresh or set the token" + ), + }, + ), + }, + "optional": { + "title": ( + "STRING", + { + "multiline": True, + "default": "youtube title", + }, + ), + "description": ( + "STRING", + { + "multiline": True, + "default": "youtube description", + }, + ), + "tags": ( + "STRING", + { + "multiline": True, + "default": "tag1, tag2", + }, + ), + "privacy": ( + ["public", "private", "protected"], + { + "default": "public", + }, + ), + "category": ( + [ + "Film & Animation", + "Autos & Vehicles", + "Music", + "Pets & Animals", + "Sports", + "Travel & Events", + "Gaming", + "People & Blogs", + "Comedy", + "Entertainment", + "News & Politics", + "Howto & Style", + "Education", + "Science & Technology", + "Nonprofits & Activism", + ], + { + "default": "Entertainment", + }, + ), + "yt_thumbnail": ("IMAGE",), + # "yt_thumbnail": (sorted(image_files), {"image_show": True}), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_YOUTUBE_DATA",) + RETURN_NAMES = ("youtube_data",) + + def pass_data( + self, + target_channel, + title, + description, + tags, + privacy, + category, + yt_thumbnail=None, + ): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + target_channel_id = get_account_id( + "youtube", target_channel, social_man_data, "channel_name", "channel_id" + ) + ret = { + "target_channel": target_channel_id, + "title": title, + "description": description, + "tags": tags, + "privacy": privacy, + "category": category, + } + if yt_thumbnail is not None: + ret["thumbnail"] = images_tensor_to_file( + yt_thumbnail, folder_paths.get_output_directory(), 4 + )[0] + return (ret,) + + +class FacebookPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + accounts = [ + channel.get("account_name") + for channel in social_man_data.get("facebook", []) + ] + + image_files = image_files_only() + return { + "required": { + "target_account": ( + accounts, + { + "default": ( + accounts[0] if accounts else "Refresh or set the token" + ), + }, + ), + }, + "optional": { + "caption": ( + "STRING", + { + "multiline": True, + "default": "facebook caption", + }, + ), + "post_to_story": ("BOOLEAN", {"default": True}), + "fb_thumbnail": ("IMAGE",), + # "fb_thumbnail": (sorted(image_files), {"image_show": True}), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_FACEBOOK_DATA",) + RETURN_NAMES = ("facebook_data",) + + def pass_data(self, target_account, caption, post_to_story, fb_thumbnail=None): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + target_account_id = get_account_id( + "facebook", target_account, social_man_data, "account_name", "account_id" + ) + ret = { + "target_account": target_account_id, + "caption": caption, + "post_to_story": post_to_story, + } + if fb_thumbnail is not None: + ret["thumbnail"] = images_tensor_to_file( + fb_thumbnail, folder_paths.get_output_directory(), 4 + )[0] + + return (ret,) + + +class InstagramPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + accounts = [ + channel.get("account_name") + for channel in social_man_data.get("instagram", []) + ] + image_files = image_files_only() + return { + "required": { + "target_account": ( + accounts, + { + "default": ( + accounts[0] if accounts else "Refresh or set the token" + ), + }, + ), + }, + "optional": { + "caption": ( + "STRING", + { + "multiline": True, + "default": "instagram caption", + }, + ), + "post_to_story": ("BOOLEAN", {"default": True}), + "insta_thumbnail": ("IMAGE",), + # "insta_thumbnail": (sorted(image_files), {"image_show": True}), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_INSTAGRAM_DATA",) + RETURN_NAMES = ("instagram_data",) + + def pass_data(self, target_account, caption, post_to_story, insta_thumbnail=None): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + target_account_id = get_account_id( + "instagram", target_account, social_man_data, "account_name", "account_id" + ) + ret = { + "target_account": target_account_id, + "caption": caption, + "post_to_story": post_to_story, + } + + if insta_thumbnail is not None: + ret["thumbnail"] = images_tensor_to_file( + insta_thumbnail, folder_paths.get_output_directory(), 4 + )[0] + + return (ret,) + + +class TwitterPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "optional": { + "caption": ( + "STRING", + { + "multiline": True, + "default": "twitter caption", + }, + ), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_TWITTER_DATA",) + RETURN_NAMES = ("twitter_data",) + + def pass_data(self, caption): + return ({"caption": caption},) + + +class LinkedinPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "optional": { + "caption": ( + "STRING", + { + "multiline": True, + "default": "linkedin caption", + }, + ), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_LINKEDIN_DATA",) + RETURN_NAMES = ("linkedin_data",) + + def pass_data(self, caption): + return ({"caption": caption},) + + +class PinterestPosterData: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + + boards = [ + channel.get("board_name") + for channel in social_man_data.get("pinterest", []) + ] + + image_files = image_files_only() + return { + "required": { + "target_board": ( + boards, + { + "default": ( + boards[0] if boards else "Refresh or set the token" + ), + }, + ), + }, + "optional": { + "title": ( + "STRING", + { + "multiline": True, + "default": "Pinterest Title", + }, + ), + "description": ( + "STRING", + { + "multiline": True, + "default": "Pinterest Description", + }, + ), + "link": ( + "STRING", + { + "multiline": False, + "default": "", + }, + ), + "pin_thumbnail": ("IMAGE",), + # "pin_thumbnail": (sorted(image_files), {"image_show": True}), + }, + } + + FUNCTION = "pass_data" + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("MLT_SM_PINTEREST_DATA",) + RETURN_NAMES = ("pinterest_data",) + + def pass_data(self, target_board, title, description, link, pin_thumbnail=None): + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + target_board_id = get_account_id( + "pinterest", target_board, social_man_data, "board_name", "board_id" + ) + ret = { + "target_board": target_board_id, + "title": title, + "description": description, + "link": link, + } + if pin_thumbnail is not None: + ret["thumbnail"] = images_tensor_to_file( + pin_thumbnail, folder_paths.get_output_directory(), 4 + )[0] + return (ret,) diff --git a/SocialMan/SocialManPoster.py b/SocialMan/SocialManPoster.py new file mode 100644 index 0000000..b6bab15 --- /dev/null +++ b/SocialMan/SocialManPoster.py @@ -0,0 +1,390 @@ +import requests +from pathlib import Path +import os +import re +import json +from ..Common.Utils import ( + read_json_from_file, + write_json_to_file, + mask_string, + upload_file_to_signed_s3, + is_image, + is_gif, + is_video, + get_video_duration, +) +from server import PromptServer +from aiohttp import web +import base64 +import copy +from ..constants import MLTASK_COMFYUI_API_URL, SOCIAL_MAN_KEYS_FILE + +routes = PromptServer.instance.routes + + +@routes.get("/socialman/token") +async def get_token(request): + # the_data = await request.post() + # the_data now holds a dictionary of the values sent + # SocialManPoster.handle_my_message(the_data) + # token = os.environ.get("SOCIAL_MAN_TOKEN", "N/A") + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + token = social_man_data.get("token", "") + return web.json_response({"token": mask_string(token)}) + + +@routes.post("/socialman/token") +async def set_token(request): + data = await request.post() + token = data["token"] + write_json_to_file(SOCIAL_MAN_KEYS_FILE, json.loads(token)) + return web.json_response( + { + "status": "success", + "message": "Done", + }, + status=200, + ) + + +current_password = "" + + +@routes.post("/socialman/password") +async def set_token(request): + global current_password + data = await request.post() + password = data["password"] + if is_valid_password(password) == False: + return web.json_response( + { + "status": "error", + "message": 'Please set a valid password or click on the "Get New Token" Button', + }, + status=500, + ) + current_password = password + return web.json_response( + { + "status": "success", + "message": "Done", + }, + status=200, + ) + + +def is_valid_token(s): + pattern = r"^[0-9a-f]{32}$" + return bool(re.match(pattern, s)) + + +def is_valid_password(s): + pattern = r"^.{4,}$" + return bool(re.match(pattern, s)) + + +def handle_finalizing_post(postID, social_man_token): + auth_token = base64.b64encode( + f"{social_man_token}:{current_password}".encode("utf-8") + ) + headers = { + "Authorization": auth_token, + } + post_payload = { + "postID": postID, + } + response = requests.post( + MLTASK_COMFYUI_API_URL + "/complete-post", + headers=headers, + json=post_payload, + timeout=30, + ) + + if response.status_code == 200: + pass + elif response.status_code == 500: + text_json = json.loads(response.text) + PromptServer.instance.send_sync( + "comfyui.socialman.error", + { + "customError": ( + text_json["customError"] if "customError" in text_json else "" + ) + }, + ) + print(f"Error posting: {response.text}") + else: + PromptServer.instance.send_sync("comfyui.socialman.error.unknown", {}) + print(f"Error posting: {response.text}") + + +def handle_uploading_media_files( + show_status_banner, + file_path, + main, + response_json, + youtube_data, + facebook_data, + instagram_data, + pinterest_data, +): + if show_status_banner == True: + PromptServer.instance.send_sync( + "comfyui.socialman.status.update", {"status": "Uploading main file"} + ) + + upload_file_to_signed_s3(file_path, main) + + for platform in [ + "youtube", + "facebook", + "instagram", + "pinterest", + ]: + platform_data = locals()[f"{platform}_data"] + if platform in response_json: + upload_url = response_json[platform] + if show_status_banner == True: + PromptServer.instance.send_sync( + "comfyui.socialman.status.update", + {"status": f"Uploading {platform} thumbnail"}, + ) + upload_file_to_signed_s3(platform_data["thumbnail"], upload_url) + + +def handle_post_creation_response( + response, + show_status_banner, + file_path, + social_man_token, + youtube_data, + facebook_data, + instagram_data, + pinterest_data, + prepare_only, +): + if response.status_code == 200: + response_json = response.json() + postID = response_json["postID"] + link = response_json["link"] + main = response_json["main"] + handle_uploading_media_files( + show_status_banner, + file_path, + main, + response_json, + youtube_data, + facebook_data, + instagram_data, + pinterest_data, + ) + if show_status_banner == True: + PromptServer.instance.send_sync( + "comfyui.socialman.success", + {"link": link}, + ) + if prepare_only == False: + handle_finalizing_post(postID, social_man_token) + return link + elif response.status_code == 403: + text_json = json.loads(response.text) + PromptServer.instance.send_sync( + "comfyui.socialman.error", + { + "customError": ( + text_json["customError"] if "customError" in text_json else "" + ) + }, + ) + print(f"Error posting: {response.text}") + else: + print(response.text) + PromptServer.instance.send_sync("comfyui.socialman.error.unknown", {}) + print(f"Error posting: {response.text}") + + +def create_post( + file_path, + social_man_token, + post_data, + tiktok_data, + youtube_data, + facebook_data, + instagram_data, + twitter_data, + linkedin_data, + pinterest_data, + show_status_banner, + prepare_only, +): + if is_gif(file_path): + raise Exception("Gif files not supported at the moment") + + tiktok_data = copy.deepcopy(tiktok_data) + youtube_data = copy.deepcopy(youtube_data) + facebook_data = copy.deepcopy(facebook_data) + instagram_data = copy.deepcopy(instagram_data) + twitter_data = copy.deepcopy(twitter_data) + linkedin_data = copy.deepcopy(linkedin_data) + pinterest_data = copy.deepcopy(pinterest_data) + + if tiktok_data is not None: + tiktok_data["video_cover_timestamp_ms"] = 0 + if is_video(file_path): + video_duration = get_video_duration(file_path) + tiktok_data["video_cover_timestamp_ms"] = ( + tiktok_data["video_cover_timestamp_percent_from_0_to_1"] + * video_duration + * 1000 + ) + + if is_image(file_path): + # youtube doesnt support community image posting through api at the moment + youtube_data = {} + if "thumbnail" in facebook_data: + del facebook_data["thumbnail"] + if "thumbnail" in instagram_data: + del instagram_data["thumbnail"] + if "thumbnail" in pinterest_data: + del pinterest_data["thumbnail"] + + auth_token = base64.b64encode( + f"{social_man_token}:{current_password}".encode("utf-8") + ) + # Prepare post data + post_payload = { + "mainFilename": file_path, + "postData": post_data if post_data is not None else {}, + } + + # Handle platform-specific data and thumbnails + for platform in [ + "tiktok", + "youtube", + "facebook", + "instagram", + "twitter", + "linkedin", + "pinterest", + ]: + platform_data = locals()[f"{platform}_data"] + if platform_data: + post_payload[f"{platform}Data"] = platform_data + + headers = { + "Authorization": auth_token, + } + + response = requests.post( + MLTASK_COMFYUI_API_URL + "/create-post", + headers=headers, + json=post_payload, + timeout=30, + ) + + return handle_post_creation_response( + response, + show_status_banner, + file_path, + social_man_token, + youtube_data, + facebook_data, + instagram_data, + pinterest_data, + prepare_only, + ) + + +class SocialManPoster: + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "token": ( + "SM_TOKEN", + {"default": "put your token here and DO NOT SHARE IT WITH ANYONE"}, + ), + }, + "optional": { + "media_file_path": ( + "STRING", + {"default": "use media to poster node", "forceInput": True}, + ), + "post_data": ("MLT_SM_POST_DATA", {"forceInput": True}), + "tiktok_data": ("MLT_SM_TIKTOK_DATA", {"forceInput": True}), + "youtube_data": ("MLT_SM_YOUTUBE_DATA", {"forceInput": True}), + "facebook_data": ("MLT_SM_FACEBOOK_DATA", {"forceInput": True}), + "instagram_data": ("MLT_SM_INSTAGRAM_DATA", {"forceInput": True}), + "twitter_data": ("MLT_SM_TWITTER_DATA", {"forceInput": True}), + "linkedin_data": ("MLT_SM_LINKEDIN_DATA", {"forceInput": True}), + "pinterest_data": ("MLT_SM_PINTEREST_DATA", {"forceInput": True}), + "show_status_banner": ("BOOLEAN", {"default": False}), + "prepare_only": ("BOOLEAN", {"default": False}), + # "display_message": ("DISPLAY_MSG",), + }, + } + + FUNCTION = "post_everwhere" + OUTPUT_NODE = True + + CATEGORY = "MLTask/SocialMan" + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("post_link",) + + def post_everwhere( + self, + token, + media_file_path, + post_data=None, + tiktok_data=None, + youtube_data=None, + facebook_data=None, + instagram_data=None, + twitter_data=None, + linkedin_data=None, + pinterest_data=None, + show_status_banner=False, + prepare_only=False, + # display_message=None, + ): + if len(current_password) == 0: + PromptServer.instance.send_sync( + "comfyui.socialman.error", + {"customError": "no_password_set"}, + ) + return () + + social_man_data = read_json_from_file(SOCIAL_MAN_KEYS_FILE) + if "token" not in social_man_data: + PromptServer.instance.send_sync( + "comfyui.socialman.error", + {"customError": "no_token"}, + ) + return () + social_man_token = social_man_data["token"] + if is_valid_token(social_man_token) == False: + PromptServer.instance.send_sync( + "comfyui.socialman.error", + {"customError": "invalid_token"}, + ) + return () + post_url = create_post( + media_file_path[0], + social_man_token, + post_data, + tiktok_data, + youtube_data, + facebook_data, + instagram_data, + twitter_data, + linkedin_data, + pinterest_data, + show_status_banner, + prepare_only, + ) + + return (post_url,) diff --git a/UtilNodes/TextGenerator.py b/UtilNodes/TextGenerator.py new file mode 100644 index 0000000..302e66d --- /dev/null +++ b/UtilNodes/TextGenerator.py @@ -0,0 +1,240 @@ +from PIL import Image, ImageDraw, ImageFont +import os +import io +from datetime import datetime +import textwrap +import string +from Common.Utils import ( + get_system_font_files, + images_data_to_tensor, +) + + +def get_default_font(): + try: + return ImageFont.load_default() + except IOError: + possible_fonts = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux + "/Library/Fonts/Arial.ttf", # macOS + "C:\\Windows\\Fonts\\arial.ttf", # Windows + ] + for font_path in possible_fonts: + if os.path.exists(font_path): + return font_path + raise IOError("No usable default font found.") + + +def create_text_image( + text, + width, + height, + font, + font_size=None, + text_color="white", + bg_color="black", + offset_x=0, + offset_y=0, +): + img = Image.new("RGB", (width, height), color=bg_color) + draw = ImageDraw.Draw(img) + + try: + if isinstance(font, str): + font = ImageFont.truetype(font, font_size or 20) + elif font_size: + font = font.font_variant(size=font_size) + + # Calculate the average character width + avg_char_width = ( + sum(font.getbbox(char)[2] for char in string.ascii_lowercase) / 26 + ) + + # Calculate the maximum characters per line + max_char_count = int(width / avg_char_width) + + # Wrap the text + lines = textwrap.wrap(text, width=max_char_count) + + # Calculate total text height + line_height = font.getbbox("hg")[3] - font.getbbox("hg")[1] + text_height = len(lines) * line_height + + # Calculate starting Y position to center the text block + y = offset_y + (height - text_height) / 2 + + for line in lines: + # Get line width + line_width = font.getbbox(line)[2] + + # Calculate starting X position to center this line + x = offset_x + (width - line_width) / 2 + + # Draw the line + draw.text((x, y), line, font=font, fill=text_color) + + # Move to next line + y += line_height + + except Exception as e: + print(f"Error creating image: {str(e)}") + return None + + return img + + +def create_text_image_pil( + text, + width, + height, + font, + font_size=None, + text_color="white", + bg_color="black", + offset_x=0, + offset_y=0, +): + img = create_text_image( + text, width, height, font, font_size, text_color, bg_color, offset_x, offset_y + ) + img_byte_arr = io.BytesIO() + img.save(img_byte_arr, format="PNG") + img_byte_arr.seek(0) + return Image.open(img_byte_arr) + + +class MLTaskUtilsTextImageGenerator: + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "text": ( + "STRING", + { + "multiline": True, + "default": "text here", + }, + ), + }, + "optional": { + "width": ( + "INT", + { + "default": 512, + }, + ), + "height": ( + "INT", + { + "default": 512, + }, + ), + "font_size": ( + "INT", + { + "default": 100, + }, + ), + # TODO move this to a widget + "font_name": (sorted(get_system_font_files()),), + "offset_x": ( + "INT", + { + "default": 0, + }, + ), + "offset_y": ( + "INT", + { + "default": 0, + }, + ), + # TODO color widget + # "text_color": ( + # "INT", + # { + # "default": 0, + # "min": 0, + # "max": 0xFFFFFF, + # "step": 1, + # "display": "color", + # }, + # ), + # "bg_color": ( + # "INT", + # { + # "default": 0, + # "min": 0, + # "max": 0x000000, + # "step": 1, + # "display": "color", + # }, + # ), + }, + } + + OUTPUT_NODE = True + FUNCTION = "generate_text_image" + CATEGORY = "MLTask/SocialMan/Utils" + RETURN_TYPES = ( + "IMAGE", + "MASK", + "IMAGE", + "MASK", + ) + RETURN_NAMES = ( + "text_image", + "text_image_mask", + "text_image_inverted", + "text_image_mask_inverted", + ) + + def generate_text_image( + self, text, width, height, font_size, font_name, offset_x, offset_y + ): + + # font = get_default_font() if args.font_path is None else args.font_path + font = font_name # "Arial Rounded Bold.ttf" + text_color = "black" + bg_color = "white" + img = create_text_image_pil( + text, + width, + height, + font, + font_size, + text_color, + bg_color, + offset_x, + offset_y, + ) + # INVERTED + text_color = "white" + bg_color = "black" + img_inverted = create_text_image_pil( + text, + width, + height, + font, + font_size, + text_color, + bg_color, + offset_x, + offset_y, + ) + if img: + # script_dir = os.path.dirname(os.path.abspath(__file__)) + # timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + # file_name = f"text_image_{timestamp}.png" + # # file_path = os.path.join(script_dir, file_name) + # file_path = f"{folder_paths.get_output_directory()}/{file_name}" + # img.save(file_path) + # print(f"Image saved as {file_path}") + # return images_file_to_tensor(file_path) + + return images_data_to_tensor(img) + images_data_to_tensor(img_inverted) + else: + print("Failed to create image.") diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f6ca986 --- /dev/null +++ b/__init__.py @@ -0,0 +1,45 @@ +from .SocialMan.SocialManPoster import SocialManPoster +from .SocialMan.PosterData import ( + SocialManPostData, + TiktokPosterData, + YoutubePosterData, + FacebookPosterData, + InstagramPosterData, + TwitterPosterData, + LinkedinPosterData, + PinterestPosterData, + SocialManMediaToPoster, +) +from .UtilNodes.TextGenerator import MLTaskUtilsTextImageGenerator + + +NODE_CLASS_MAPPINGS = { + "MLTaskUtilsTextImageGenerator": MLTaskUtilsTextImageGenerator, + "SocialManMediaToPoster": SocialManMediaToPoster, + "SocialManPostData": SocialManPostData, + "SocialManPoster": SocialManPoster, + "TiktokPosterData": TiktokPosterData, + "YoutubePosterData": YoutubePosterData, + "FacebookPosterData": FacebookPosterData, + "InstagramPosterData": InstagramPosterData, + "TwitterPosterData": TwitterPosterData, + "LinkedinPosterData": LinkedinPosterData, + "PinterestPosterData": PinterestPosterData, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "MLTaskUtilsTextImageGenerator": "MLTask Utils Text Image Generator", + "SocialManMediaToPoster": "SocialMan Media To Poster", + "SocialManPostData": "SocialMan Post Data", + "SocialManPoster": "SocialMan Poster", + "TiktokPosterData": "Tiktok Poster Data", + "YoutubePosterData": "Youtube Poster Data", + "FacebookPosterData": "Facebook Poster Data", + "InstagramPosterData": "Instagram Poster Data", + "TwitterPosterData": "Twitter Poster Data", + "LinkedinPosterData": "Linkedin Poster Data", + "PinterestPosterData": "Pinterest Poster Data", +} + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..327a979 --- /dev/null +++ b/constants.py @@ -0,0 +1,4 @@ +import os + +MLTASK_COMFYUI_API_URL = "https://comfy.api.mltask.com/v1" +SOCIAL_MAN_KEYS_FILE = os.path.dirname(os.path.realpath(__file__)) + "/socialman.json" diff --git a/js/socialman.js b/js/socialman.js new file mode 100644 index 0000000..f17af62 --- /dev/null +++ b/js/socialman.js @@ -0,0 +1,909 @@ +import { app } from "../../scripts/app.js"; +import { $el } from "../../scripts/ui.js"; +import { api } from "../../scripts/api.js"; +import { ComfyWidgets } from "../../scripts/widgets.js"; + +function show_message(msg) { + app.ui.dialog.show(msg); + app.ui.dialog.element.style.zIndex = 10010; +} + +async function getTokenFromServer() { + const response = await api.fetchApi("/socialman/token", { + method: "GET", + }); + const data = await response.json(); + return data.token; +} +async function setTokenInServer(node_id, token) { + const body = new FormData(); + body.append("token", token); + body.append("node_id", node_id); + const response = await api.fetchApi("/socialman/token", { + method: "POST", + body, + }); + return response; +} +async function setPasswordInServer(node_id, token) { + const body = new FormData(); + body.append("password", token); + body.append("node_id", node_id); + const response = await api.fetchApi("/socialman/password", { + method: "POST", + body, + }); + return response; +} +// Adds an upload button to the nodes +//custom nodes reference +// web/extensions/core/noteNode.js +const convertIdClass = (text) => text.replaceAll(".", "_"); +const idExt = "mltask.socialman"; + +function renderHeader(name) { + return $el("td", [ + $el("label", { + textContent: name, + for: convertIdClass(`${idExt}.mltask.socialman.token`), + }), + ]); +} +var socialManToken = "****"; + +function renderGetTokenButton() { + return $el("button", { + textContent: "Show token", + onclick: () => {}, + style: { + display: "block", + width: "100%", + }, + }); +} + +function renderTokenlabel(val) { + return $el("label", { + style: { display: "flex" }, + textContent: `Social Man Token:`, + for: convertIdClass(`${idExt}.mltask.socialman.token`), + }); +} +function renderTokenToken(val) { + return $el("label", { + style: { display: "flex", fontWeight: "bold" }, + textContent: `${socialManToken}`, + for: convertIdClass(`${idExt}.mltask.socialman.token`), + }); +} +function renderSettings(name, val) { + // return $el("td", [renderTokenTextField(val), renderGetTokenButton()]); + return $el("td", [renderTokenlabel(val), renderTokenToken(val)]); +} +var statusMessage = "123"; +var currentInputToken = ""; +app.registerExtension({ + name: `${idExt}.poster`, + async init() { + socialManToken = await getTokenFromServer(); + app.ui.settings.addSetting({ + id: `${idExt}.mltask`, + name: "🤖 MLTask", + defaultValue: true, + type: (name, sett, val) => { + return $el("tr", [renderHeader(name), renderSettings(name, val)]); + }, + }); + }, + getCustomWidgets(app) { + return { + STRING_URL: (node, inputName, inputData, app) => { + const container = $el("div", { + style: { + height: "100%", + margin: 0, + padding: 0, + display: "flex", + }, + }); + const openPostButton = $el("button", { + textContent: "Open Post on SocialMan", + style: { + backgroundColor: "#58c7f3", + width: "100%", + }, + onclick: async () => { + window.open(inputData.value); + }, + }); + container.appendChild(openPostButton); + const linkWidget = node.addDOMWidget( + inputName, + "container", + container, + { + getValue() { + return node.widgets[0].value; + }, + setValue(v) { + inputData.value = node.widgets[0].value; + }, + } + ); + return { widget: linkWidget }; + }, + DISPLAY_MSG: (node, inputName, inputData, app) => { + console.log("node = "); + console.log(node); + console.log("====="); + const container = $el("div", { + style: { + height: "20px", + margin: 0, + padding: 0, + display: "flex", + }, + }); + const messageLabel = $el( + "label", + { + style: { + color: "#f8f8f8", + display: "block", + margin: "10px 0 0 0", + fontWeight: "bold", + textDecoration: "none", + }, + }, + [statusMessage] + ); + container.appendChild(messageLabel); + const widget = node.addDOMWidget(inputName, "container", container); + + return { + widget, + }; + }, + SM_TOKEN: (node, inputName, inputData, app) => { + const tokenSetDiv = $el("div", { + style: { + height: "20px", + margin: 0, + padding: 0, + display: "flex", + }, + }); + + const tokenInput = $el("input", { + type: "password", + style: { + width: "55%", + }, + value: + inputData[1]?.default || + "put your token here and DO NOT SHARE IT WITH ANYONE", + oninput: () => { + currentInputToken = tokenInput.value; + }, + }); + tokenInput.addEventListener("focus", function () { + // Your function when the input field is clicked (focused) + tokenInput.value = ""; + // You can add any additional functionality here + }); + + tokenInput.addEventListener("blur", function () { + tokenInput.value = + "put your token here and DO NOT SHARE IT WITH ANYONE"; + }); + const setTokenButton = $el("button", { + textContent: "Set Token", + style: { + backgroundColor: "#58c7f3", + width: "45%", + }, + onclick: async () => { + try { + const response = await setTokenInServer( + node.id, + currentInputToken + ); + const data = await response.json(); + if (!response.ok) { + throw new Error( + data.message || "An error occurred while setting the token" + ); + } + show_message("Token Set 🎉"); + } catch (error) { + show_message(error.message); + } + + currentInputToken = ""; + }, + }); + tokenSetDiv.appendChild(tokenInput); + tokenSetDiv.appendChild(setTokenButton); + + const passwordSetDiv = $el("div", { + style: { + height: "20px", + margin: 0, + padding: 0, + display: "flex", + }, + }); + + const passwordInput = $el("input", { + type: "password", + style: { + width: "45%", + }, + value: + inputData[1]?.default || + "put your token here and DO NOT SHARE IT WITH ANYONE", + oninput: () => { + currentInputToken = passwordInput.value; + }, + }); + passwordInput.addEventListener("focus", function () { + // Your function when the input field is clicked (focused) + passwordInput.value = ""; + // You can add any additional functionality here + }); + + passwordInput.addEventListener("blur", function () { + passwordInput.value = + "put your password here and DO NOT SHARE IT WITH ANYONE"; + }); + const setPasswordButton = $el("button", { + textContent: "Set Password", + style: { + backgroundColor: "#58c7f3", + width: "55%", + }, + onclick: async () => { + try { + const response = await setPasswordInServer( + node.id, + currentInputToken + ); + const data = await response.json(); + if (!response.ok) { + throw new Error( + data.message || "An error occurred while setting the password" + ); + } + show_message("Password Set 🎉"); + } catch (error) { + show_message(error.message); + } + + currentInputToken = ""; + }, + }); + passwordSetDiv.appendChild(passwordInput); + passwordSetDiv.appendChild(setPasswordButton); + + let getNewTokenDiv = $el("div", { + style: { + height: "20px", + margin: 0, + padding: 0, + display: "flex", + }, + }); + let getNewTokenButton = $el("button", { + textContent: "Get New Token", + style: { + backgroundColor: "#ffd40d", + width: "100%", + }, + onclick: () => { + window.open("https://mltask.com/user/comfyui"); + }, + }); + getNewTokenDiv.appendChild(getNewTokenButton); + + const container = $el("div", { + style: { + height: "100%", + margin: 0, + padding: 0, + display: "flex", + }, + }); + + container.appendChild(tokenSetDiv); + container.appendChild(passwordSetDiv); + container.appendChild(getNewTokenDiv); + + const tokenWidget = node.addDOMWidget( + inputName, + "container", + container, + { + getHeight() { + return 80; + }, + getValue() { + return tokenInput.value; + }, + setValue(v) { + tokenInput.value = v; + }, + } + ); + tokenWidget.serialize = false; + + return { + widget: tokenWidget, + }; + }, + BETTER_IMAGE_UPLOAD(node, inputName, inputData, app) { + const targetInputName = inputName.split("__")[0]; + const imageWidget = node.widgets.find( + (w) => w.name === (inputData[1]?.widget ?? targetInputName) + ); + let uploadWidget; + + function showImage(name) { + const img = new Image(); + img.onload = () => { + node.imgs = [img]; + app.graph.setDirtyCanvas(true); + }; + let folder_separator = name.lastIndexOf("/"); + let subfolder = ""; + if (folder_separator > -1) { + subfolder = name.substring(0, folder_separator); + name = name.substring(folder_separator + 1); + } + img.src = api.apiURL( + `/view?filename=${encodeURIComponent( + name + )}&type=input&subfolder=${subfolder}${app.getPreviewFormatParam()}${app.getRandParam()}` + ); + node.setSizeForImage?.(); + } + + var default_value = imageWidget.value; + Object.defineProperty(imageWidget, "value", { + set: function (value) { + this._real_value = value; + }, + + get: function () { + let value = ""; + if (this._real_value) { + value = this._real_value; + } else { + return default_value; + } + + if (value.filename) { + let real_value = value; + value = ""; + if (real_value.subfolder) { + value = real_value.subfolder + "/"; + } + + value += real_value.filename; + + if (real_value.type && real_value.type !== "input") + value += ` [${real_value.type}]`; + } + return value; + }, + }); + + // Add our own callback to the combo widget to render an image when it changes + const cb = node.callback; + imageWidget.callback = function () { + showImage(imageWidget.value); + if (cb) { + return cb.apply(this, arguments); + } + }; + + // On load if we have a value then render the image + // The value isnt set immediately so we need to wait a moment + // No change callbacks seem to be fired on initial setting of the value + requestAnimationFrame(() => { + if (imageWidget.value) { + showImage(imageWidget.value); + } + }); + + async function uploadFile(file, updateNode, pasted = false) { + try { + // Wrap file in formdata so it includes filename + const body = new FormData(); + body.append("image", file); + if (pasted) body.append("subfolder", "pasted"); + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body, + }); + + if (resp.status === 200) { + const data = await resp.json(); + // Add the file to the dropdown list and update the widget value + let path = data.name; + if (data.subfolder) path = data.subfolder + "/" + path; + + if (!imageWidget.options.values.includes(path)) { + imageWidget.options.values.push(path); + } + + if (updateNode) { + showImage(path); + imageWidget.value = path; + } + } else { + alert(resp.status + " - " + resp.statusText); + } + } catch (error) { + alert(error); + } + } + + const fileInput = document.createElement("input"); + Object.assign(fileInput, { + type: "file", + accept: "image/jpeg,image/png,image/webp", + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + await uploadFile(fileInput.files[0], true); + } + }, + }); + document.body.append(fileInput); + + // Create the button widget for selecting the files + uploadWidget = node.addWidget("button", inputName, "image", () => { + fileInput.click(); + }); + uploadWidget.label = "choose file to upload"; + uploadWidget.serialize = false; + + // Add handler to check if an image is being dragged over our node + node.onDragOver = function (e) { + if (e.dataTransfer && e.dataTransfer.items) { + const image = [...e.dataTransfer.items].find( + (f) => f.kind === "file" + ); + return !!image; + } + + return false; + }; + + // On drop upload files + node.onDragDrop = function (e) { + console.log("onDragDrop called"); + let handled = false; + for (const file of e.dataTransfer.files) { + if (file.type.startsWith("image/")) { + uploadFile(file, !handled); // Dont await these, any order is fine, only update on first one + handled = true; + } + } + + return handled; + }; + + node.pasteFile = function (file) { + if (file.type.startsWith("image/")) { + const is_pasted = + file.name === "image.png" && + file.lastModified - Date.now() < 2000; + uploadFile(file, true, is_pasted); + return true; + } + return false; + }; + + return { widget: uploadWidget }; + }, + }; + }, + registerCustomNodes() { + // class BetterRerouteNode { + // color = LGraphCanvas.node_colors.yellow.color; + // // bgcolor = LGraphCanvas.node_colors.yellow.bgcolor; + // bgcolor = "#FFF000"; + // groupcolor = LGraphCanvas.node_colors.yellow.groupcolor; + // constructor() { + // if (!this.properties) { + // this.properties = {}; + // } + // this.properties.showOutputText = BetterRerouteNode.defaultVisibility; + // this.properties.horizontal = false; + // this.addInput("", "*"); + // this.addOutput(this.properties.showOutputText ? "*" : "", "*"); + // this.onAfterGraphConfigured = function () { + // requestAnimationFrame(() => { + // this.onConnectionsChange(LiteGraph.INPUT, null, true, null); + // }); + // }; + // this.onConnectionsChange = function ( + // type, + // index, + // connected, + // link_info + // ) { + // this.applyOrientation(); + // // Prevent multiple connections to different types when we have no input + // if (connected && type === LiteGraph.OUTPUT) { + // // Ignore wildcard nodes as these will be updated to real types + // const types = new Set( + // this.outputs[0].links + // .map((l) => app.graph.links[l].type) + // .filter((t) => t !== "*") + // ); + // if (types.size > 1) { + // const linksToDisconnect = []; + // for (let i = 0; i < this.outputs[0].links.length - 1; i++) { + // const linkId = this.outputs[0].links[i]; + // const link = app.graph.links[linkId]; + // linksToDisconnect.push(link); + // } + // for (const link of linksToDisconnect) { + // const node = app.graph.getNodeById(link.target_id); + // node.disconnectInput(link.target_slot); + // } + // } + // } + // // Find root input + // let currentNode = this; + // let updateNodes = []; + // let inputType = null; + // let inputNode = null; + // while (currentNode) { + // updateNodes.unshift(currentNode); + // const linkId = currentNode.inputs[0].link; + // if (linkId !== null) { + // const link = app.graph.links[linkId]; + // if (!link) return; + // const node = app.graph.getNodeById(link.origin_id); + // const type = node.constructor.type; + // if (type === "Better Reroute") { + // if (node === this) { + // // We've found a circle + // currentNode.disconnectInput(link.target_slot); + // currentNode = null; + // } else { + // // Move the previous node + // currentNode = node; + // } + // } else { + // // We've found the end + // inputNode = currentNode; + // inputType = node.outputs[link.origin_slot]?.type ?? null; + // break; + // } + // } else { + // // This path has no input node + // currentNode = null; + // break; + // } + // } + // // Find all outputs + // const nodes = [this]; + // let outputType = null; + // while (nodes.length) { + // currentNode = nodes.pop(); + // const outputs = + // (currentNode.outputs ? currentNode.outputs[0].links : []) || []; + // if (outputs.length) { + // for (const linkId of outputs) { + // const link = app.graph.links[linkId]; + // // When disconnecting sometimes the link is still registered + // if (!link) continue; + // const node = app.graph.getNodeById(link.target_id); + // const type = node.constructor.type; + // if (type === "Better Reroute") { + // // Follow reroute nodes + // nodes.push(node); + // updateNodes.push(node); + // } else { + // // We've found an output + // const nodeOutType = + // node.inputs && + // node.inputs[link?.target_slot] && + // node.inputs[link.target_slot].type + // ? node.inputs[link.target_slot].type + // : null; + // if ( + // inputType && + // inputType !== "*" && + // nodeOutType !== inputType + // ) { + // // The output doesnt match our input so disconnect it + // node.disconnectInput(link.target_slot); + // } else { + // outputType = nodeOutType; + // } + // } + // } + // } else { + // // No more outputs for this path + // } + // } + // const displayType = inputType || outputType || "*"; + // const color = LGraphCanvas.link_type_colors[displayType]; + // let widgetConfig; + // let targetWidget; + // let widgetType; + // // Update the types of each node + // for (const node of updateNodes) { + // // If we dont have an input type we are always wildcard but we'll show the output type + // // This lets you change the output link to a different type and all nodes will update + // node.outputs[0].type = inputType || "*"; + // node.__outputType = displayType; + // node.outputs[0].name = node.properties.showOutputText + // ? displayType + // : ""; + // node.size = node.computeSize(); + // node.applyOrientation(); + // for (const l of node.outputs[0].links || []) { + // const link = app.graph.links[l]; + // if (link) { + // link.color = color; + // if (app.configuringGraph) continue; + // const targetNode = app.graph.getNodeById(link.target_id); + // const targetInput = targetNode.inputs?.[link.target_slot]; + // if (targetInput?.widget) { + // const config = getWidgetConfig(targetInput); + // if (!widgetConfig) { + // widgetConfig = config[1] ?? {}; + // widgetType = config[0]; + // } + // if (!targetWidget) { + // targetWidget = targetNode.widgets?.find( + // (w) => w.name === targetInput.widget.name + // ); + // } + // const merged = mergeIfValid(targetInput, [ + // config[0], + // widgetConfig, + // ]); + // if (merged.customConfig) { + // widgetConfig = merged.customConfig; + // } + // } + // } + // } + // } + // for (const node of updateNodes) { + // if (widgetConfig && outputType) { + // node.inputs[0].widget = { name: "value" }; + // setWidgetConfig( + // node.inputs[0], + // [widgetType ?? displayType, widgetConfig], + // targetWidget + // ); + // } else { + // setWidgetConfig(node.inputs[0], null); + // } + // } + // if (inputNode) { + // const link = app.graph.links[inputNode.inputs[0].link]; + // if (link) { + // link.color = color; + // } + // } + // }; + // this.clone = function () { + // const cloned = BetterRerouteNode.prototype.clone.apply(this); + // cloned.removeOutput(0); + // cloned.addOutput(this.properties.showOutputText ? "*" : "", "*"); + // cloned.size = cloned.computeSize(); + // return cloned; + // }; + // // This node is purely frontend and does not impact the resulting prompt so should not be serialized + // this.isVirtualNode = true; + // } + // getExtraMenuOptions(_, options) { + // options.unshift( + // { + // content: + // (this.properties.showOutputText ? "Hide" : "Show") + " Type", + // callback: () => { + // this.properties.showOutputText = !this.properties.showOutputText; + // if (this.properties.showOutputText) { + // this.outputs[0].name = + // this.__outputType || this.outputs[0].type; + // } else { + // this.outputs[0].name = ""; + // } + // this.size = this.computeSize(); + // this.applyOrientation(); + // app.graph.setDirtyCanvas(true, true); + // }, + // }, + // { + // content: + // (BetterRerouteNode.defaultVisibility ? "Hide" : "Show") + + // " Type By Default", + // callback: () => { + // BetterRerouteNode.setDefaultTextVisibility( + // !BetterRerouteNode.defaultVisibility + // ); + // }, + // }, + // { + // // naming is inverted with respect to LiteGraphNode.horizontal + // // LiteGraphNode.horizontal == true means that + // // each slot in the inputs and outputs are layed out horizontally, + // // which is the opposite of the visual orientation of the inputs and outputs as a node + // content: + // "Set " + (this.properties.horizontal ? "Horizontal" : "Vertical"), + // callback: () => { + // this.properties.horizontal = !this.properties.horizontal; + // this.applyOrientation(); + // }, + // } + // ); + // } + // applyOrientation() { + // this.horizontal = this.properties.horizontal; + // if (this.horizontal) { + // // we correct the input position, because LiteGraphNode.horizontal + // // doesn't account for title presence + // // which reroute nodes don't have + // this.inputs[0].pos = [this.size[0] / 2, 0]; + // } else { + // delete this.inputs[0].pos; + // } + // app.graph.setDirtyCanvas(true, true); + // } + // computeSize() { + // return [ + // this.properties.showOutputText && this.outputs && this.outputs.length + // ? Math.max( + // 75, + // LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 + + // 40 + // ) + // : 75, + // 26, + // ]; + // } + // static setDefaultTextVisibility(visible) { + // BetterRerouteNode.defaultVisibility = visible; + // if (visible) { + // localStorage["MLTask.BetterRerouteNode.DefaultVisibility"] = "true"; + // } else { + // delete localStorage["MLTask.BetterRerouteNode.DefaultVisibility"]; + // } + // } + // } + // // Load default visibility + // BetterRerouteNode.setDefaultTextVisibility( + // !!localStorage["MLTask.BetterRerouteNode.DefaultVisibility"] + // ); + // LiteGraph.registerNodeType( + // "Better Reroute", + // Object.assign(BetterRerouteNode, { + // title_mode: LiteGraph.NO_TITLE, + // title: "Better Reroute", + // collapsable: false, + // }) + // ); + // BetterRerouteNode.category = "MLTask/Utils"; + }, + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // if (nodeData?.input?.optional?.thumbnail?.[1]?.is_url === true) { + // nodeData.input.optional.is_url = ["STRING_URL"]; + // } + if (nodeData?.input?.optional?.thumbnail?.[1]?.image_show === true) { + nodeData.input.optional.thumbnail__IU = ["BETTER_IMAGE_UPLOAD"]; + } + if (nodeData?.input?.optional?.yt_thumbnail?.[1]?.image_show === true) { + nodeData.input.optional.yt_thumbnail__IU = ["BETTER_IMAGE_UPLOAD"]; + } + if (nodeData?.input?.optional?.fb_thumbnail?.[1]?.image_show === true) { + nodeData.input.optional.fb_thumbnail__IU = ["BETTER_IMAGE_UPLOAD"]; + } + if (nodeData?.input?.optional?.insta_thumbnail?.[1]?.image_show === true) { + nodeData.input.optional.insta_thumbnail__IU = ["BETTER_IMAGE_UPLOAD"]; + } + if (nodeData?.input?.optional?.pin_thumbnail?.[1]?.image_show === true) { + nodeData.input.optional.pin_thumbnail__IU = ["BETTER_IMAGE_UPLOAD"]; + } + switch (nodeData.name) { + case "SocialManPoster": { + api.addEventListener( + "comfyui.socialman.status.update", + async ({ detail }) => { + show_message(detail["status"]); + console.log(detail["status"]); + } + ); + api.addEventListener( + "comfyui.socialman.success", + async ({ detail }) => { + show_message( + `Posted 🎉🎉🎉, see post on socialman` + ); + } + ); + + api.addEventListener("comfyui.socialman.error", async ({ detail }) => { + let defaultAnchor = `create a new token`; + let subscribeAnchor = `Subscribe On SocialMan`; + + let customErrorMessage = `please make sure you set the token, and the password correctly, if you forgot the password ${defaultAnchor}`; + const { customError } = detail; + + if (customError == "user_not_subscribed") + customErrorMessage = `You are not subscribed: please subscribe to complete this post, ${subscribeAnchor}`; + + if (customError == "no_token") + customErrorMessage = `No token provided: please make sure you set the token, and the password correctly ${defaultAnchor}`; + + if (customError == "invalid_token") + customErrorMessage = `Invalid token: ${customErrorMessage}`; + + if (customError == "bad_token") + customErrorMessage = `Bad token: please make sure you set the token, ${defaultAnchor}`; + + if (customError == "token_revoked") + customErrorMessage = `Token revoked: ${defaultAnchor}`; + + if (customError == "token_expired") + customErrorMessage = `Token expired: ${defaultAnchor}`; + + if (customError == "wrong_password") + customErrorMessage = `Wrong password: please set the password correctly, if you forgot the password ${defaultAnchor}`; + + if (customError == "no_password_set") + customErrorMessage = `No password provided: please set the password correctly, if you forgot the password ${defaultAnchor}`; + + show_message(`${customErrorMessage}`); + }); + api.addEventListener( + "comfyui.socialman.error.unknown", + async ({ detail }) => { + show_message( + "Whoops! Something went wrong, make sure the video is valid, then please try again later" + ); + } + ); + + break; + } + + default: { + break; + } + } + }, +}); + +//how load image works +//nodes.py +// class LoadImage: +// @classmethod +// def INPUT_TYPES(s): +// input_dir = folder_paths.get_input_directory() +// files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] +// return {"required": +// {"image": (sorted(files), {"image_upload": True})}, +// } + +//how its widget is made +// web/extensions/core/uploadImage.js +// app.registerExtension({ +// name: "Comfy.UploadImage", +// async beforeRegisterNodeDef(nodeType, nodeData, app) { +// if (nodeData?.input?.required?.image?.[1]?.image_upload === true) { +// nodeData.input.required.upload = ["IMAGEUPLOAD"]; +// //see other input types here web/scripts/widgets.js +// } +// }, +// }); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a0402cc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +aiohttp \ No newline at end of file