├── .github └── workflows │ └── publish.yml ├── README.md ├── __init__.py ├── beautyPass.py ├── beautyPassSequence.py ├── depthPass.py ├── depthPassSequence.py ├── maskPass.py ├── maskPassSequence.py ├── outlinePass.py ├── outlinePassSequence.py ├── playbookAspectRatioSelect.py ├── playbookBoolean.py ├── playbookFloat.py ├── playbookImage.py ├── playbookLoraSelect.py ├── playbookNumber.py ├── playbookSeed.py ├── playbookText.py ├── playbookVideo.py ├── pyproject.toml ├── renderResult.py └── template.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | paths: 9 | - "pyproject.toml" 10 | 11 | jobs: 12 | publish-node: 13 | name: Publish Custom Node to registry 14 | runs-on: ubuntu-latest 15 | # if this is a forked repository. Skipping the workflow. 16 | if: github.event.repository.fork == false 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v4 20 | - name: Publish Custom Node 21 | uses: Comfy-Org/publish-node-action@main 22 | with: 23 | ## Add your own personal access token to your Github Repository secrets and reference it here. 24 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Playbook3D Nodes for ComfyUI 2 | 3 | ![image](https://github.com/user-attachments/assets/499d84d5-6d01-4eaf-88bd-5cd7af773903) 4 | 5 | This repository provides custom nodes for ComfyUI that allow you to seamlessly integrate Playbook3D renders into your workflows. 6 | 7 | **Features:** 8 | 9 | * **Dedicated Render Nodes:** Access individual nodes for different render passes: 10 | * **Beauty:** Get the final rendered image. 11 | * **Canny:** Extract edge information with Canny edge detection. 12 | * **Depth:** Obtain depth information as a grayscale image. 13 | * **Mask:** Generate masks for specific objects or materials. 14 | 15 | **Getting Started:** 16 | 17 | 1. **Clone the Repository:** 18 | ```bash 19 | git clone https://github.com/playbook3d/playbook-nodes.git 20 | 21 | ## Install 22 | Copy the contents of this repository into your custom_nodes folder within your ComfyUI installation directory. 23 | 24 | ## Obtain an API Key 25 | Visit beta.playbook3d.com to sign up for a Playbook3D account and obtain your API key. 26 | 27 | ## Configure Nodes 28 | In your ComfyUI workflow, add the desired Playbook3D render nodes and input your API key in the designated field. 29 | 30 | ## Requirements 31 | -ComfyUI (latest version recommended) 32 | -Playbook3D account and API key 33 | 34 | ## Usage 35 | Connect the Playbook3D render nodes to your existing ComfyUI workflows. Each node outputs an image corresponding to the selected render pass. You can then use these images as inputs for other nodes in your workflow, such as image processing, compositing, or style transfer. 36 | 37 | ## Example Workflow: 38 | Use a Playbook3D Beauty node to generate a base render. 39 | Connect a Playbook3D Canny node to extract edges from the render. 40 | Use a Combine node to overlay the edges on the original render for a stylized effect. 41 | 42 | ## Contributing: 43 | Contributions are welcome! If you find any bugs or have suggestions for improvements, please open an issue or submit a pull request.   44 | 45 | ## License: 46 | This project is licensed under the MIT License. 47 | 48 | ## Contact: 49 | For questions or support, please contact support@playbook3d.com or join the Playbook3D Discord server.   50 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .depthPass import DepthRenderPass 2 | from .outlinePass import OutlineRenderPass 3 | from .maskPass import MaskRenderPass 4 | from .beautyPass import BeautyRenderPass 5 | from .renderResult import UploadRenderResult 6 | from .playbookBoolean import PlaybookBoolean 7 | from .playbookFloat import PlaybookFloat 8 | from .playbookNumber import PlaybookNumber 9 | from .playbookText import PlaybookText 10 | from .playbookImage import PlaybookImage 11 | from .beautyPassSequence import BeautyRenderPassSequence 12 | from .depthPassSequence import DepthRenderPassSequence 13 | from .maskPassSequence import MaskRenderPassSequence 14 | from .outlinePassSequence import OutlineRenderPassSequence 15 | from .playbookVideo import PlaybookVideo 16 | from .playbookAspectRatioSelect import PlaybookAspectRatioSelect 17 | from .playbookLoraSelect import PlaybookLoRASelection 18 | from .playbookSeed import PlaybookSeed 19 | 20 | NODE_CLASS_MAPPINGS = { 21 | "Playbook Depth": DepthRenderPass, 22 | "Playbook Depth Sequence": DepthRenderPassSequence, 23 | "Playbook Outline": OutlineRenderPass, 24 | "Playbook Outline Sequence": OutlineRenderPassSequence, 25 | "Playbook Mask": MaskRenderPass, 26 | "Playbook Mask Sequence": MaskRenderPassSequence, 27 | "Playbook Beauty": BeautyRenderPass, 28 | "Playbook Beauty Sequence": BeautyRenderPassSequence, 29 | "Playbook Render Result": UploadRenderResult, 30 | "Playbook Boolean": PlaybookBoolean, 31 | "Playbook Float": PlaybookFloat, 32 | "Playbook Number": PlaybookNumber, 33 | "Playbook Text": PlaybookText, 34 | "Playbook Image": PlaybookImage, 35 | "Playbook Video": PlaybookVideo, 36 | "Playbook Aspect Ratio Select": PlaybookAspectRatioSelect, 37 | "Playbook LoRA Select": PlaybookLoRASelection, 38 | "Playbook Seed": PlaybookSeed, 39 | } 40 | 41 | NODE_DISPLAY_NAME_MAPPINGS = { 42 | "Playbook Depth": "Playbook Depth Render Pass", 43 | "Playbook Depth Sequence": "Playbook Depth Render Pass Sequence", 44 | "Playbook Outline": "Playbook Outline Render Pass", 45 | "Playbook Outline Sequence": "Playbook Outline Render Pass Sequence", 46 | "Playbook Mask": "Playbook Mask Render Pass", 47 | "Playbook Mask Sequence": "Playbook Mask Render Pass Sequence", 48 | "Playbook Beauty": "Playbook Beauty Render Pass", 49 | "Playbook Beauty Sequence": "Playbook Beauty Render Pass Sequence", 50 | "Playbook Render Result": "Playbook Render Result", 51 | "Playbook Boolean": "Playbook Boolean (External)", 52 | "Playbook Float": "Playbook Float (External)", 53 | "Playbook Number": "Playbook Number (External)", 54 | "Playbook Text": "Playbook Text (External)", 55 | "Playbook Image": "Playbook Image (External)", 56 | "Playbook Video": "Playbook Video (External)", 57 | "Playbook Aspect Ratio Select": "Playbook Aspect Ratio Select (External)", 58 | "Playbook LoRA Select": "Playbook LoRA Select (External)", 59 | "Playbook Seed": "Playbook Seed", 60 | } 61 | 62 | 63 | __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] 64 | -------------------------------------------------------------------------------- /beautyPass.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | 9 | class BeautyRenderPass: 10 | def __init__(self): 11 | pass 12 | 13 | @classmethod 14 | def INPUT_TYPES(cls): 15 | return { 16 | "required": { 17 | "api_key": ("STRING", { "multiline": False }), 18 | }, 19 | "optional": { 20 | "run_id": ("STRING", { "multiline": False }), 21 | "default_value": ("IMAGE",) 22 | } 23 | } 24 | 25 | @classmethod 26 | def IS_CHANGED(cls, image): 27 | # always update 28 | m = hashlib.sha256() 29 | m.update(str(time.time()).encode("utf-8")) 30 | return m.digest().hex() 31 | 32 | RETURN_TYPES = ("IMAGE",) 33 | RETURN_NAMES = ("Image",) 34 | FUNCTION = "parse_beauty" 35 | OUTPUT_NODE = {False} 36 | CATEGORY = "Playbook 3D" 37 | 38 | def parse_beauty(self, api_key, run_id=None, default_value=None): 39 | """ 40 | This method fetches the user's access_token from the 41 | Playbook3D token endpoint, checks for run_id presence, 42 | and then makes a request to fetch the Beauty image. 43 | """ 44 | base_url = "https://accounts.playbook3d.com" 45 | 46 | # 1) Ensure API key is provided 47 | if not api_key or not api_key.strip(): 48 | print("No api_key provided. Returning default image.") 49 | return [default_value] 50 | 51 | # 2) Get user_token from the token service 52 | user_token = None 53 | try: 54 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 55 | if jwt_request is not None: 56 | user_token = jwt_request.json().get("access_token", None) 57 | if not user_token: 58 | print("Could not retrieve user_token. Returning default image.") 59 | return [default_value] 60 | except Exception as e: 61 | print(f"Error retrieving token: {e}") 62 | return [default_value] 63 | 64 | 65 | # 3) Construct the endpoint using run_id and fetch the Beauty pass 66 | try: 67 | headers = {"Authorization": f"Bearer {user_token}"} 68 | if run_id: 69 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 70 | else: 71 | url = f"{base_url}/upload-assets/get-download-urls" 72 | 73 | beauty_request = requests.get(url, headers=headers) 74 | if beauty_request.status_code == 200: 75 | beauty_url = beauty_request.json().get("beauty") 76 | if beauty_url: 77 | beauty_response = requests.get(beauty_url) 78 | image = Image.open(BytesIO(beauty_response.content)) 79 | image = ImageOps.exif_transpose(image) 80 | image = image.convert("RGB") 81 | image = np.array(image).astype(np.float32) / 255.0 82 | image = torch.from_numpy(image)[None,] 83 | return [image] 84 | else: 85 | print("No 'beauty' key found in the JSON response. Returning default image.") 86 | return [default_value] 87 | else: 88 | print(f"Beauty request returned status code {beauty_request.status_code}") 89 | return [default_value] 90 | 91 | except Exception as e: 92 | print(f"Error retrieving beauty pass: {e}") 93 | return [default_value] 94 | 95 | 96 | NODE_CLASS_MAPPINGS = { 97 | "Playbook Beauty": BeautyRenderPass 98 | } 99 | 100 | NODE_DISPLAY_NAME_MAPPINGS = { 101 | "Playbook Beauty": "Playbook Beauty Render Passes" 102 | } -------------------------------------------------------------------------------- /beautyPassSequence.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | import zipfile 9 | import tempfile 10 | import os 11 | 12 | class BeautyRenderPassSequence: 13 | def __init__(self): 14 | pass 15 | 16 | @classmethod 17 | def INPUT_TYPES(cls): 18 | return { 19 | "required": { 20 | "api_key": ("STRING", {"multiline": False}), 21 | }, 22 | "optional": { 23 | "run_id": ("STRING", {"multiline": False}) 24 | } 25 | } 26 | 27 | @classmethod 28 | def IS_CHANGED(cls, *args, **kwargs): 29 | # always update 30 | m = hashlib.sha256() 31 | m.update(str(time.time()).encode("utf-8")) 32 | return m.digest().hex() 33 | 34 | RETURN_TYPES = ("IMAGE",) 35 | RETURN_NAMES = ("Images",) 36 | FUNCTION = "parse_beauty_sequence" 37 | OUTPUT_NODE = False 38 | CATEGORY = "Playbook 3D" 39 | 40 | def parse_beauty_sequence(self, api_key, run_id=None): 41 | base_url = "https://accounts.playbook3d.com" 42 | 43 | # 1) Check if api_key is valid 44 | if not api_key or not api_key.strip(): 45 | raise ValueError("No api_key provided.") 46 | 47 | # 2) Retrieve user token 48 | user_token = None 49 | try: 50 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 51 | if jwt_request is not None and jwt_request.status_code == 200: 52 | user_token = jwt_request.json().get("access_token", None) 53 | if not user_token: 54 | raise ValueError("Could not retrieve user token.") 55 | else: 56 | raise ValueError("API Key not found or incorrect.") 57 | except Exception as e: 58 | print(f"Error retrieving token: {e}") 59 | raise ValueError("API Key not found or incorrect.") 60 | 61 | 62 | # 4) Construct the endpoint using run_id and fetch the Beauty ZIP 63 | try: 64 | headers = {"Authorization": f"Bearer {user_token}"} 65 | if run_id: 66 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 67 | else: 68 | url = f"{base_url}/upload-assets/get-download-urls" 69 | 70 | beauty_request = requests.get(url, headers=headers) 71 | if beauty_request.status_code == 200: 72 | beauty_url = beauty_request.json().get("beauty_zip", None) 73 | if not beauty_url: 74 | raise ValueError("No beauty zip found for the provided parameters.") 75 | 76 | # Download the zip file 77 | beauty_response = requests.get(beauty_url) 78 | if beauty_response.status_code != 200: 79 | raise ValueError("Failed to download the beauty zip file.") 80 | 81 | zip_content = beauty_response.content 82 | 83 | # Extract images from the zip file 84 | images_batch = self.extract_images_from_zip(zip_content) 85 | return (images_batch,) 86 | 87 | else: 88 | raise ValueError( 89 | f"Failed to retrieve beauty URL. Status code: {beauty_request.status_code}" 90 | ) 91 | 92 | except Exception as e: 93 | print(f"Error processing beauty sequence: {e}") 94 | raise ValueError("Beauty pass not uploaded or processing error occurred.") 95 | 96 | def extract_images_from_zip(self, zip_content): 97 | images = [] 98 | with tempfile.TemporaryDirectory() as tmpdirname: 99 | zip_path = os.path.join(tmpdirname, "beauty_sequence.zip") 100 | with open(zip_path, 'wb') as f: 101 | f.write(zip_content) 102 | 103 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 104 | image_files = sorted( 105 | [f for f in zip_ref.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg'))] 106 | ) 107 | for file_name in image_files: 108 | with zip_ref.open(file_name) as img_file: 109 | image = Image.open(BytesIO(img_file.read())) 110 | image = ImageOps.exif_transpose(image) 111 | image = image.convert("RGB") 112 | image = np.array(image).astype(np.float32) / 255.0 113 | image = torch.from_numpy(image)[None,] 114 | images.append(image) 115 | 116 | if images: 117 | images_batch = torch.cat(images, dim=0) 118 | return images_batch 119 | else: 120 | raise ValueError("No images found in the zip file.") 121 | 122 | NODE_CLASS_MAPPINGS = { 123 | "Beauty Pass Sequence": BeautyRenderPassSequence 124 | } 125 | 126 | NODE_DISPLAY_NAME_MAPPINGS = { 127 | "Beauty Pass Sequence": "Beauty Pass Sequence" 128 | } -------------------------------------------------------------------------------- /depthPass.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | 9 | class DepthRenderPass: 10 | def __init__(self): 11 | pass 12 | 13 | @classmethod 14 | def INPUT_TYPES(cls): 15 | return { 16 | "required": { 17 | "api_key": ("STRING", { "multiline": False }), 18 | }, 19 | "optional": { 20 | "run_id": ("STRING", { "multiline": False }), 21 | "default_value": ("IMAGE",) 22 | } 23 | } 24 | 25 | @classmethod 26 | def IS_CHANGED(cls, image): 27 | # Always update 28 | m = hashlib.sha256() 29 | m.update(str(time.time()).encode("utf-8")) 30 | return m.digest().hex() 31 | 32 | RETURN_TYPES = ("IMAGE",) 33 | RETURN_NAMES = ("Image",) 34 | 35 | FUNCTION = "parse_depth" 36 | OUTPUT_NODE = {False} 37 | CATEGORY = "Playbook 3D" 38 | 39 | def parse_depth(self, api_key, run_id=None, default_value=None): 40 | """ 41 | This method fetches the user's access_token from the 42 | Playbook3D token endpoint, checks run_id, and then 43 | makes a request to fetch the Depth image. 44 | """ 45 | base_url = "https://accounts.playbook3d.com" 46 | 47 | # 1) Ensure API key is provided 48 | if not api_key or not api_key.strip(): 49 | print("No api_key provided. Returning default image.") 50 | return [default_value] 51 | 52 | # 2) Get user_token from the token service 53 | user_token = None 54 | try: 55 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 56 | if jwt_request is not None: 57 | user_token = jwt_request.json().get("access_token", None) 58 | if not user_token: 59 | print("Could not retrieve user_token. Returning default image.") 60 | return [default_value] 61 | except Exception as e: 62 | print(f"Error retrieving token: {e}") 63 | return [default_value] 64 | 65 | # 3) Construct the endpoint using run_id and fetch the Depth pass 66 | try: 67 | headers = {"Authorization": f"Bearer {user_token}"} 68 | if run_id: 69 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 70 | else: 71 | url = f"{base_url}/upload-assets/get-download-urls" 72 | 73 | depth_request = requests.get(url, headers=headers) 74 | if depth_request.status_code == 200: 75 | depth_url = depth_request.json().get("depth") 76 | if depth_url: 77 | depth_response = requests.get(depth_url) 78 | image = Image.open(BytesIO(depth_response.content)) 79 | image = ImageOps.exif_transpose(image) 80 | image = image.convert("RGB") 81 | image = np.array(image).astype(np.float32) / 255.0 82 | image = torch.from_numpy(image)[None,] 83 | return [image] 84 | else: 85 | print("No 'depth' key found in the JSON response. Returning default image.") 86 | return [default_value] 87 | else: 88 | print(f"Depth request returned status code {depth_request.status_code}") 89 | return [default_value] 90 | 91 | except Exception as e: 92 | print(f"Error retrieving depth pass: {e}") 93 | return [default_value] 94 | 95 | 96 | NODE_CLASS_MAPPINGS = { 97 | "Playbook Depth": DepthRenderPass 98 | } 99 | 100 | NODE_DISPLAY_NAME_MAPPINGS = { 101 | "Playbook Depth": "Playbook Depth Render Pass" 102 | } -------------------------------------------------------------------------------- /depthPassSequence.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | import zipfile 9 | import tempfile 10 | import os 11 | 12 | class DepthRenderPassSequence: 13 | def __init__(self): 14 | pass 15 | 16 | @classmethod 17 | def INPUT_TYPES(cls): 18 | return { 19 | "required": { 20 | "api_key": ("STRING", {"multiline": False}), 21 | }, 22 | "optional": { 23 | "run_id": ("STRING", {"multiline": False}) 24 | } 25 | } 26 | 27 | @classmethod 28 | def IS_CHANGED(cls, image): 29 | # always update 30 | m = hashlib.sha256() 31 | m.update(str(time.time()).encode("utf-8")) 32 | return m.digest().hex() 33 | 34 | RETURN_TYPES = ("IMAGE",) 35 | RETURN_NAMES = ("Images",) 36 | FUNCTION = "parse_depth_sequence" 37 | OUTPUT_NODE = False 38 | CATEGORY = "Playbook 3D" 39 | 40 | def parse_depth_sequence(self, api_key, run_id=None): 41 | """ 42 | Fetches a zip file containing depth pass frames for a given run_id. 43 | Unzips the images and returns them as a batched tensor. 44 | """ 45 | base_url = "https://accounts.playbook3d.com" 46 | 47 | # 1) Check if api_key is valid 48 | if not api_key or not api_key.strip(): 49 | raise ValueError("No api_key provided.") 50 | 51 | # 2) Retrieve user token 52 | user_token = None 53 | try: 54 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 55 | if jwt_request is not None and jwt_request.status_code == 200: 56 | user_token = jwt_request.json().get("access_token", None) 57 | if not user_token: 58 | raise ValueError("Could not retrieve user token.") 59 | else: 60 | raise ValueError("API Key not found or incorrect.") 61 | except Exception as e: 62 | print(f"Error retrieving token: {e}") 63 | raise ValueError("API Key not found or incorrect.") 64 | 65 | 66 | # 3) Construct the endpoint using run_id 67 | try: 68 | headers = {"Authorization": f"Bearer {user_token}"} 69 | if run_id: 70 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 71 | else: 72 | url = f"{base_url}/upload-assets/get-download-urls" 73 | 74 | depth_request = requests.get(url, headers=headers) 75 | if depth_request.status_code == 200: 76 | depth_url = depth_request.json().get("depth_zip", None) 77 | if not depth_url: 78 | raise ValueError("No depth zip found for the provided parameters.") 79 | 80 | # 5) Download the zip file 81 | depth_response = requests.get(depth_url) 82 | if depth_response.status_code != 200: 83 | raise ValueError("Failed to download the depth zip file.") 84 | 85 | zip_content = depth_response.content 86 | 87 | # Extract images from the zip file 88 | images_batch = self.extract_images_from_zip(zip_content) 89 | return (images_batch,) 90 | else: 91 | raise ValueError( 92 | f"Failed to retrieve depth URL. Status code: {depth_request.status_code}" 93 | ) 94 | except Exception as e: 95 | print(f"Error processing depth sequence: {e}") 96 | raise ValueError("Depth pass not uploaded or processing error occurred.") 97 | 98 | def extract_images_from_zip(self, zip_content): 99 | images = [] 100 | with tempfile.TemporaryDirectory() as tmpdirname: 101 | zip_path = os.path.join(tmpdirname, "depth_sequence.zip") 102 | with open(zip_path, 'wb') as f: 103 | f.write(zip_content) 104 | 105 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 106 | image_files = sorted( 107 | [f for f in zip_ref.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg'))] 108 | ) 109 | for file_name in image_files: 110 | with zip_ref.open(file_name) as img_file: 111 | image = Image.open(BytesIO(img_file.read())) 112 | image = ImageOps.exif_transpose(image) 113 | image = image.convert("RGB") 114 | image = np.array(image).astype(np.float32) / 255.0 115 | image = torch.from_numpy(image)[None,] 116 | images.append(image) 117 | 118 | if images: 119 | images_batch = torch.cat(images, dim=0) 120 | return images_batch 121 | else: 122 | raise ValueError("No images found in the zip file.") 123 | 124 | 125 | NODE_CLASS_MAPPINGS = { 126 | "Depth Pass Sequence": DepthRenderPassSequence 127 | } 128 | 129 | NODE_DISPLAY_NAME_MAPPINGS = { 130 | "Depth Pass Sequence": "Depth Pass Sequence" 131 | } 132 | -------------------------------------------------------------------------------- /maskPass.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps, ImageFilter 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | 9 | class MaskRenderPass: 10 | def __init__(self): 11 | pass 12 | 13 | @classmethod 14 | def INPUT_TYPES(cls): 15 | return { 16 | "required": { 17 | "api_key": ("STRING", {"multiline": False}), 18 | "blur_size": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 50.0}) 19 | }, 20 | "optional": { 21 | "run_id": ("STRING", {"multiline": False}), 22 | "default_value": ("IMAGE",), 23 | } 24 | } 25 | 26 | @classmethod 27 | def IS_CHANGED(cls, image): 28 | # Always update 29 | m = hashlib.sha256() 30 | m.update(str(time.time()).encode("utf-8")) 31 | return m.digest().hex() 32 | 33 | RETURN_TYPES = ( 34 | "IMAGE", 35 | "MASK", 36 | "MASK", 37 | "MASK", 38 | "MASK", 39 | "MASK", 40 | "MASK", 41 | "MASK", 42 | "MASK" 43 | ) 44 | 45 | RETURN_NAMES = ( 46 | "image", 47 | "mask_1", 48 | "mask_2", 49 | "mask_3", 50 | "mask_4", 51 | "mask_5", 52 | "mask_6", 53 | "mask_7", 54 | "mask_8" 55 | ) 56 | 57 | FUNCTION = "parse_mask" 58 | OUTPUT_NODE = {False} 59 | CATEGORY = "Playbook 3D" 60 | 61 | def parse_mask(self, api_key, blur_size, run_id=None, default_value=None): 62 | """ 63 | Fetches the mask pass for the specified run_id, applies 64 | optional Gaussian blur, and returns the composite + 65 | individual color-based masks. 66 | """ 67 | base_url = "https://accounts.playbook3d.com" 68 | 69 | # 1) Check API key 70 | if not api_key or not api_key.strip(): 71 | print("No api_key provided. Returning default masks.") 72 | return [default_value] * 9 73 | 74 | # 2) Retrieve user token 75 | user_token = None 76 | try: 77 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 78 | print("jwt request: ", jwt_request) 79 | if jwt_request is not None: 80 | user_token = jwt_request.json().get("access_token", None) 81 | print("user token: ", user_token) 82 | if not user_token: 83 | print("Could not retrieve user_token. Returning default masks.") 84 | return [default_value] * 9 85 | except Exception as e: 86 | print(f"Error retrieving token: {e}") 87 | return [default_value] * 9 88 | 89 | 90 | # 3) Construct the URL with run_id 91 | if run_id: 92 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 93 | else: 94 | url = f"{base_url}/upload-assets/get-download-urls" 95 | 96 | # 5) Request the mask pass 97 | try: 98 | headers = {"Authorization": f"Bearer {user_token}"} 99 | mask_request = requests.get(url, headers=headers) 100 | 101 | if mask_request.status_code == 200: 102 | mask_url = mask_request.json().get("mask") 103 | if not mask_url: 104 | raise ValueError("Mask pass URL not found for this run_id.") 105 | 106 | mask_response = requests.get(mask_url) 107 | image = Image.open(BytesIO(mask_response.content)) 108 | image = ImageOps.exif_transpose(image) 109 | image = image.convert("RGB") 110 | 111 | # Convert to tensor 112 | composite_mask = np.array(image) 113 | composite_mask_tensor = torch.from_numpy( 114 | composite_mask.astype(np.float32) / 255.0 115 | )[None,] 116 | 117 | # Define the 8 known color codes in the mask 118 | color_codes = [ 119 | "#ffe906", 120 | "#0589d6", 121 | "#a2d4d5", 122 | "#000016", 123 | "#00ad58", 124 | "#f084cf", 125 | "#ee9e3e", 126 | "#e6000c" 127 | ] 128 | color_tuples = [ 129 | tuple(int(c.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) 130 | for c in color_codes 131 | ] 132 | 133 | # Generate individual masks 134 | individual_masks = [] 135 | for color in color_tuples: 136 | mask_array = ((composite_mask == np.array(color)).all(axis=2)).astype(np.uint8) * 255 137 | mask_image = Image.fromarray(mask_array, mode='L') 138 | if blur_size > 0: 139 | mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=blur_size)) 140 | 141 | mask_tensor = torch.from_numpy( 142 | np.array(mask_image).astype(np.float32) / 255.0 143 | )[None,] 144 | individual_masks.append(mask_tensor) 145 | 146 | # Ensure all masks have the same shape if needed 147 | for i in range(len(individual_masks)): 148 | if individual_masks[i].shape != individual_masks[0].shape: 149 | individual_masks[i] = torch.nn.functional.interpolate( 150 | individual_masks[i], 151 | size=individual_masks[0].shape[2:], 152 | mode='nearest' 153 | ) 154 | 155 | # Return composite + 8 masks 156 | return [ 157 | composite_mask_tensor, 158 | individual_masks[0], 159 | individual_masks[1], 160 | individual_masks[2], 161 | individual_masks[3], 162 | individual_masks[4], 163 | individual_masks[5], 164 | individual_masks[6], 165 | individual_masks[7] 166 | ] 167 | else: 168 | print(f"Mask request returned status code {mask_request.status_code}") 169 | return [default_value] * 9 170 | 171 | except Exception as e: 172 | print(f"Error while processing masks: {e}") 173 | return [default_value] * 9 174 | 175 | 176 | NODE_CLASS_MAPPINGS = { 177 | "Playbook Mask": MaskRenderPass 178 | } 179 | 180 | NODE_DISPLAY_NAME_MAPPINGS = { 181 | "Playbook Mask": "Playbook Mask Render Passes" 182 | } 183 | -------------------------------------------------------------------------------- /maskPassSequence.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps, ImageFilter 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | import zipfile 9 | import tempfile 10 | import os 11 | 12 | class MaskRenderPassSequence: 13 | def __init__(self): 14 | pass 15 | 16 | @classmethod 17 | def INPUT_TYPES(cls): 18 | return { 19 | "required": { 20 | "api_key": ("STRING", {"multiline": False}), 21 | "blur_size": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 50.0}), 22 | }, 23 | "optional": { 24 | "run_id": ("STRING", {"multiline": False}) 25 | } 26 | } 27 | 28 | @classmethod 29 | def IS_CHANGED(cls, image): 30 | # Always update 31 | m = hashlib.sha256() 32 | m.update(str(time.time()).encode("utf-8")) 33 | return m.digest().hex() 34 | 35 | RETURN_TYPES = ( 36 | "IMAGE", 37 | "MASK", 38 | "MASK", 39 | "MASK", 40 | "MASK", 41 | "MASK", 42 | "MASK", 43 | "MASK", 44 | "MASK" 45 | ) 46 | 47 | RETURN_NAMES = ( 48 | "mask_pass", 49 | "mask_1", 50 | "mask_2", 51 | "mask_3", 52 | "mask_4", 53 | "mask_5", 54 | "mask_6", 55 | "mask_7", 56 | "mask_8" 57 | ) 58 | 59 | FUNCTION = "parse_mask_sequence" 60 | OUTPUT_NODE = False 61 | CATEGORY = "Playbook 3D" 62 | 63 | def parse_mask_sequence(self, api_key, blur_size, run_id=None): 64 | """ 65 | Fetches a ZIP file containing mask passes for the specified run_id, 66 | extracts them, and returns both the composite mask + 8 individual 67 | color-based masks as a sequence (batch). 68 | """ 69 | base_url = "https://accounts.playbook3d.com" 70 | 71 | # 1) Check if api_key is valid 72 | if not api_key or not api_key.strip(): 73 | raise ValueError("No api_key provided.") 74 | 75 | # 2) Retrieve user token 76 | user_token = None 77 | try: 78 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 79 | if jwt_request is not None and jwt_request.status_code == 200: 80 | user_token = jwt_request.json().get("access_token", None) 81 | if not user_token: 82 | raise ValueError("Could not retrieve user token.") 83 | else: 84 | raise ValueError("API Key not found or incorrect.") 85 | except Exception as e: 86 | print(f"Error retrieving token: {e}") 87 | raise ValueError("API Key not found or incorrect.") 88 | 89 | # 3) Construct the endpoint using run_id 90 | try: 91 | headers = {"Authorization": f"Bearer {user_token}"} 92 | if run_id: 93 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 94 | else: 95 | url = f"{base_url}/upload-assets/get-download-urls" 96 | 97 | mask_request = requests.get(url, headers=headers) 98 | if mask_request.status_code == 200: 99 | mask_url = mask_request.json().get("mask_zip", None) 100 | if not mask_url: 101 | raise ValueError("No mask zip found for the provided parameters.") 102 | 103 | # 5) Download the zip file 104 | mask_response = requests.get(mask_url) 105 | if mask_response.status_code != 200: 106 | raise ValueError("Failed to download the mask zip file.") 107 | 108 | # Process the zip file 109 | zip_content = mask_response.content 110 | ( 111 | composite_masks_batch, 112 | mask_1_batch, 113 | mask_2_batch, 114 | mask_3_batch, 115 | mask_4_batch, 116 | mask_5_batch, 117 | mask_6_batch, 118 | mask_7_batch, 119 | mask_8_batch 120 | ) = self.extract_masks_from_zip(zip_content, blur_size) 121 | 122 | return [ 123 | composite_masks_batch, 124 | mask_1_batch, 125 | mask_2_batch, 126 | mask_3_batch, 127 | mask_4_batch, 128 | mask_5_batch, 129 | mask_6_batch, 130 | mask_7_batch, 131 | mask_8_batch 132 | ] 133 | else: 134 | raise ValueError( 135 | f"Failed to retrieve mask URL. Status code: {mask_request.status_code}" 136 | ) 137 | except Exception as e: 138 | print(f"Error processing mask sequence: {e}") 139 | raise ValueError("Mask pass not uploaded or processing error occurred.") 140 | 141 | def extract_masks_from_zip(self, zip_content, blur_size): 142 | """ 143 | Extracts mask images from the provided ZIP content. Returns: 144 | composite_masks_batch, mask_1_batch, ..., mask_8_batch 145 | """ 146 | composite_masks = [] 147 | individual_masks_list = [[] for _ in range(8)] 148 | 149 | with tempfile.TemporaryDirectory() as tmpdirname: 150 | zip_path = os.path.join(tmpdirname, "mask_sequence.zip") 151 | with open(zip_path, 'wb') as f: 152 | f.write(zip_content) 153 | 154 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 155 | # Sort the images based on file names 156 | image_files = sorted( 157 | [f for f in zip_ref.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg'))] 158 | ) 159 | for file_name in image_files: 160 | # Read image data from the zip file 161 | with zip_ref.open(file_name) as img_file: 162 | image = Image.open(BytesIO(img_file.read())) 163 | image = ImageOps.exif_transpose(image) 164 | image = image.convert("RGB") 165 | composite_mask = np.array(image) 166 | 167 | # Composite mask stored as is (RGB) 168 | composite_mask_tensor = torch.from_numpy( 169 | composite_mask.astype(np.float32) / 255.0 170 | )[None,] 171 | composite_masks.append(composite_mask_tensor) 172 | 173 | # Predefined color codes 174 | color_codes = [ 175 | "#ffe906", 176 | "#0589d6", 177 | "#a2d4d5", 178 | "#000016", 179 | "#00ad58", 180 | "#f084cf", 181 | "#ee9e3e", 182 | "#e6000c" 183 | ] 184 | color_tuples = [ 185 | tuple(int(color.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) 186 | for color in color_codes 187 | ] 188 | 189 | # Create a mask for each color 190 | for idx, color in enumerate(color_tuples): 191 | mask_array = ( 192 | (composite_mask == np.array(color)).all(axis=2) 193 | ).astype(np.uint8) * 255 194 | 195 | mask_image = Image.fromarray(mask_array, mode='L') 196 | if blur_size > 0: 197 | mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=blur_size)) 198 | 199 | mask_tensor = torch.from_numpy( 200 | np.array(mask_image).astype(np.float32) / 255.0 201 | )[None,] 202 | individual_masks_list[idx].append(mask_tensor) 203 | 204 | # Concatenate composite masks into a single batch 205 | composite_masks_batch = torch.cat(composite_masks, dim=0) 206 | 207 | # Concatenate each color mask list into a batch 208 | individual_masks_batches = [ 209 | torch.cat(masks, dim=0) if masks else None 210 | for masks in individual_masks_list 211 | ] 212 | 213 | return ( 214 | composite_masks_batch, 215 | *individual_masks_batches 216 | ) 217 | 218 | NODE_CLASS_MAPPINGS = { 219 | "Mask Pass Sequence": MaskRenderPassSequence 220 | } 221 | 222 | NODE_DISPLAY_NAME_MAPPINGS = { 223 | "Mask Pass Sequence": "Mask Pass Sequence" 224 | } 225 | -------------------------------------------------------------------------------- /outlinePass.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | 9 | class OutlineRenderPass: 10 | def __init__(self): 11 | pass 12 | 13 | @classmethod 14 | def INPUT_TYPES(cls): 15 | return { 16 | "required": { 17 | "api_key": ("STRING", { "multiline": False }), 18 | }, 19 | "optional": { 20 | "run_id": ("STRING", { "multiline": False }), 21 | "default_value": ("IMAGE",) 22 | } 23 | } 24 | 25 | @classmethod 26 | def IS_CHANGED(cls, image): 27 | # always update 28 | m = hashlib.sha256() 29 | m.update(str(time.time()).encode("utf-8")) 30 | return m.digest().hex() 31 | 32 | RETURN_TYPES = ("IMAGE",) 33 | RETURN_NAMES = ("Image",) 34 | 35 | FUNCTION = "parse_outline" 36 | OUTPUT_NODE = {False} 37 | CATEGORY = "Playbook 3D" 38 | 39 | def parse_outline(self, api_key, run_id=None, default_value=None): 40 | """ 41 | Fetches the outline pass for the specified run_id. 42 | """ 43 | base_url = "https://accounts.playbook3d.com" 44 | 45 | # 1) Check API key 46 | if not api_key or not api_key.strip(): 47 | print("No api_key provided. Returning default image.") 48 | return [default_value] 49 | 50 | # 2) Retrieve user token 51 | user_token = None 52 | try: 53 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 54 | if jwt_request is not None: 55 | user_token = jwt_request.json().get("access_token", None) 56 | if not user_token: 57 | print("Could not retrieve user_token. Returning default image.") 58 | return [default_value] 59 | except Exception as e: 60 | print(f"Error retrieving token: {e}") 61 | return [default_value] 62 | 63 | 64 | # 3) Construct the request URL with run_id 65 | if run_id: 66 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 67 | else: 68 | url = f"{base_url}/upload-assets/get-download-urls" 69 | 70 | # 4) Fetch the outline pass 71 | try: 72 | headers = {"Authorization": f"Bearer {user_token}"} 73 | outline_request = requests.get(url, headers=headers) 74 | 75 | if outline_request.status_code == 200: 76 | outline_url = outline_request.json().get("outline") 77 | if outline_url: 78 | outline_response = requests.get(outline_url) 79 | image = Image.open(BytesIO(outline_response.content)) 80 | image = ImageOps.exif_transpose(image) 81 | image = image.convert("RGB") 82 | image = np.array(image).astype(np.float32) / 255.0 83 | image = torch.from_numpy(image)[None,] 84 | return [image] 85 | else: 86 | print("No 'outline' key found in the JSON response. Returning default image.") 87 | return [default_value] 88 | else: 89 | print(f"Outline request returned status code {outline_request.status_code}") 90 | return [default_value] 91 | 92 | except Exception as e: 93 | print(f"Error retrieving outline pass: {e}") 94 | return [default_value] 95 | 96 | 97 | NODE_CLASS_MAPPINGS = { 98 | "Playbook Outline": OutlineRenderPass 99 | } 100 | 101 | NODE_DISPLAY_NAME_MAPPINGS = { 102 | "Playbook Outline": "Playbook Outline Render Pass" 103 | } 104 | -------------------------------------------------------------------------------- /outlinePassSequence.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | import zipfile 9 | import tempfile 10 | import os 11 | 12 | class OutlineRenderPassSequence: 13 | def __init__(self): 14 | pass 15 | 16 | @classmethod 17 | def INPUT_TYPES(cls): 18 | return { 19 | "required": { 20 | "api_key": ("STRING", {"multiline": False}), 21 | }, 22 | "optional": { 23 | "run_id": ("STRING", {"multiline": False}) 24 | } 25 | } 26 | 27 | @classmethod 28 | def IS_CHANGED(cls, image): 29 | # always update 30 | m = hashlib.sha256() 31 | m.update(str(time.time()).encode("utf-8")) 32 | return m.digest().hex() 33 | 34 | RETURN_TYPES = ("IMAGE",) 35 | RETURN_NAMES = ("Images",) 36 | FUNCTION = "parse_outline_sequence" 37 | OUTPUT_NODE = False 38 | CATEGORY = "Playbook 3D" 39 | 40 | def parse_outline_sequence(self, api_key, run_id=None): 41 | """ 42 | Fetches a ZIP file containing outline pass frames for a given run_id, 43 | unzips the images, and returns them as a batched tensor. 44 | """ 45 | base_url = "https://accounts.playbook3d.com" 46 | 47 | # 1) Check if api_key is valid 48 | if not api_key or not api_key.strip(): 49 | raise ValueError("No api_key provided.") 50 | 51 | # 2) Retrieve user token 52 | user_token = None 53 | try: 54 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 55 | if jwt_request is not None and jwt_request.status_code == 200: 56 | user_token = jwt_request.json().get("access_token", None) 57 | if not user_token: 58 | raise ValueError("Could not retrieve user token.") 59 | else: 60 | raise ValueError("API Key not found or incorrect.") 61 | except Exception as e: 62 | print(f"Error retrieving token: {e}") 63 | raise ValueError("API Key not found or incorrect.") 64 | 65 | # 3) Construct the endpoint using run_id 66 | try: 67 | headers = {"Authorization": f"Bearer {user_token}"} 68 | if run_id: 69 | url = f"{base_url}/upload-assets/get-download-urls/{run_id}" 70 | else: 71 | url = f"{base_url}/upload-assets/get-download-urls" 72 | 73 | outline_request = requests.get(url, headers=headers) 74 | if outline_request.status_code == 200: 75 | outline_url = outline_request.json().get("outline_zip", None) 76 | if not outline_url: 77 | raise ValueError("No outline zip found for the provided parameters.") 78 | 79 | # 5) Download the zip file 80 | outline_response = requests.get(outline_url) 81 | if outline_response.status_code != 200: 82 | raise ValueError("Failed to download the outline zip file.") 83 | 84 | zip_content = outline_response.content 85 | 86 | # Extract images from the zip file 87 | images_batch = self.extract_images_from_zip(zip_content) 88 | return (images_batch,) 89 | 90 | else: 91 | raise ValueError( 92 | f"Failed to retrieve outline URL. Status code: {outline_request.status_code}" 93 | ) 94 | except Exception as e: 95 | print(f"Error processing outline sequence: {e}") 96 | raise ValueError("Outline pass not uploaded or processing error occurred.") 97 | 98 | def extract_images_from_zip(self, zip_content): 99 | """ 100 | Extracts outline images from a ZIP. Returns a torch tensor 101 | containing all images in a batch (B, C, H, W). 102 | """ 103 | images = [] 104 | with tempfile.TemporaryDirectory() as tmpdirname: 105 | zip_path = os.path.join(tmpdirname, "outline_sequence.zip") 106 | with open(zip_path, 'wb') as f: 107 | f.write(zip_content) 108 | 109 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 110 | image_files = sorted( 111 | [f for f in zip_ref.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg'))] 112 | ) 113 | for file_name in image_files: 114 | with zip_ref.open(file_name) as img_file: 115 | image = Image.open(BytesIO(img_file.read())) 116 | image = ImageOps.exif_transpose(image) 117 | image = image.convert("RGB") 118 | image = np.array(image).astype(np.float32) / 255.0 119 | image = torch.from_numpy(image)[None,] 120 | images.append(image) 121 | 122 | if images: 123 | images_batch = torch.cat(images, dim=0) 124 | return images_batch 125 | else: 126 | raise ValueError("No images found in the outline zip file.") 127 | 128 | NODE_CLASS_MAPPINGS = { 129 | "Outline Pass Sequence": OutlineRenderPassSequence 130 | } 131 | 132 | NODE_DISPLAY_NAME_MAPPINGS = { 133 | "Outline Pass Sequence": "Outline Pass Sequence" 134 | } 135 | -------------------------------------------------------------------------------- /playbookAspectRatioSelect.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | 4 | 5 | class PlaybookAspectRatioSelect: 6 | def __init__(self): 7 | self.aspect_dict = { 8 | "1:1": (1, 1), 9 | "16:9": (16, 9), 10 | "9:16": (9, 16), 11 | "4:3": (4, 3), 12 | "3:4": (3, 4), 13 | } 14 | 15 | @classmethod 16 | def INPUT_TYPES(s): 17 | return { 18 | "required": { 19 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 20 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 21 | "default_value": (["1:1", "16:9", "9:16", "4:3", "3:4"],), 22 | } 23 | } 24 | 25 | @classmethod 26 | def IS_CHANGED(s, image): 27 | # always update 28 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 29 | return m.digest().hex() 30 | 31 | RETURN_TYPES = ("INT", "INT") 32 | RETURN_NAMES = ("x", "y") 33 | 34 | FUNCTION = "get_aspect_ratio" 35 | 36 | OUTPUT_NODE = {False} 37 | 38 | CATEGORY = "Playbook 3D" 39 | 40 | def get_aspect_ratio(self, id, label, default_value): 41 | ratio = self.aspect_dict.get(default_value, (1, 1)) 42 | 43 | return ratio 44 | 45 | 46 | NODE_CLASS_MAPPINGS = {"Playbook Aspect Ratio Select": PlaybookAspectRatioSelect} 47 | 48 | NODE_DISPLAY_NAME_MAPPINGS = { 49 | "Playbook Aspect Ratio Select": "Playbook Aspect Ratio Select" 50 | } 51 | -------------------------------------------------------------------------------- /playbookBoolean.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | 4 | class PlaybookBoolean: 5 | def __init__(self): 6 | pass 7 | 8 | @classmethod 9 | def INPUT_TYPES(s): 10 | return { 11 | "required": { 12 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 13 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 14 | "default_value": ("BOOLEAN", {"default": False}) 15 | }, 16 | } 17 | 18 | @classmethod 19 | def IS_CHANGED(s, image): 20 | # always update 21 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 22 | return m.digest().hex() 23 | 24 | RETURN_TYPES = ( "BOOLEAN", ) 25 | RETURN_NAMES = ("Boolean", ) 26 | 27 | FUNCTION = "parse_boolean" 28 | 29 | OUTPUT_NODE = { False } 30 | 31 | CATEGORY = "Playbook 3D" 32 | 33 | def parse_boolean(self, id, label, default_value): 34 | return [default_value] 35 | 36 | 37 | NODE_CLASS_MAPPINGS = { "Playbook Boolean": PlaybookBoolean } 38 | 39 | NODE_DISPLAY_NAME_MAPPINGS = { "Playbook Boolean": "Playbook Boolean (External)" } -------------------------------------------------------------------------------- /playbookFloat.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | 4 | import numpy as np 5 | 6 | class PlaybookFloat: 7 | def __init__(self): 8 | pass 9 | 10 | @classmethod 11 | def INPUT_TYPES(s): 12 | return { 13 | "required": { 14 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 15 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 16 | "min": ("FLOAT", {"multiline": False, "default": 0, "display": "number", "step": 0.01}), 17 | "max": ("FLOAT", {"multiline": False, "default": 1.0, "display": "number", "step": 0.01}), 18 | }, 19 | "optional": { 20 | "default_value": ("FLOAT", 21 | { 22 | "multiline": True, 23 | "display": "number", 24 | "min": -2147483647, 25 | "max": 2147483647, 26 | "default": 0, 27 | "step": 0.01 28 | }, 29 | ), 30 | } 31 | } 32 | 33 | @classmethod 34 | def IS_CHANGED(s, image): 35 | # always update 36 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 37 | return m.digest().hex() 38 | 39 | RETURN_TYPES = ("FLOAT",) 40 | RETURN_NAMES = ("float",) 41 | 42 | FUNCTION = "parse_float" 43 | 44 | OUTPUT_NODE = { False } 45 | 46 | CATEGORY = "Playbook 3D" 47 | 48 | def parse_float(self, id, label, min, max, default_value = None): 49 | if not id or (isinstance(id, str) and not id.strip().isdigit()): 50 | clamped_float = np.clip(default_value, min, max) 51 | return [clamped_float] 52 | clamped_float_id = np.clip(float(id), min, max) 53 | return [clamped_float_id] 54 | 55 | NODE_CLASS_MAPPINGS = { "Playbook Float": PlaybookFloat } 56 | 57 | NODE_DISPLAY_NAME_MAPPINGS = { "Playbook Float": "Playbook Float (External)" } -------------------------------------------------------------------------------- /playbookImage.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageOps 2 | import numpy as np 3 | import torch 4 | import requests 5 | from io import BytesIO 6 | import hashlib 7 | import time 8 | 9 | class PlaybookImage: 10 | def __init__(self): 11 | pass 12 | 13 | @classmethod 14 | def INPUT_TYPES(s): 15 | return { 16 | "required": { 17 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 18 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 19 | }, 20 | "optional": { 21 | "default_value": ("IMAGE",), 22 | "default_url": ("STRING", {"multiline": False, "default": ""}) 23 | }, 24 | } 25 | 26 | @classmethod 27 | def IS_CHANGED(s, image): 28 | # always update 29 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 30 | return m.digest().hex() 31 | 32 | RETURN_TYPES = ("IMAGE",) 33 | RETURN_NAMES = ("Image",) 34 | 35 | FUNCTION = "parse_image" 36 | 37 | OUTPUT_NODE = { False } 38 | 39 | CATEGORY = "Playbook 3D" 40 | 41 | def parse_image(self, id, label, default_url, default_value=None): 42 | image_url = default_url 43 | image = default_value 44 | try: 45 | if image_url.startswith('http'): 46 | image_request = requests.get(image_url) 47 | image = Image.open(BytesIO(image_request.content)) 48 | else: 49 | raise ValueError("Invalid URL") 50 | 51 | image = ImageOps.exif_transpose(image) 52 | image = image.convert("RGB") 53 | image = np.array(image).astype(np.float32) / 255.0 54 | image = torch.from_numpy(image)[ None, ] 55 | return [ image ] 56 | except Exception as e: 57 | print(f"Exception while downloading Image {e}") 58 | return [ image ] 59 | 60 | 61 | 62 | 63 | NODE_CLASS_MAPPINGS = { 64 | "Playbook Image": PlaybookImage 65 | } 66 | 67 | NODE_DISPLAY_NAME_MAPPINGS = { 68 | "Playbook Image": "Playbook Image (External)" 69 | } -------------------------------------------------------------------------------- /playbookLoraSelect.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | 4 | class PlaybookLoRASelection: 5 | def init(self): 6 | pass 7 | 8 | @classmethod 9 | def INPUT_TYPES(s): 10 | return { 11 | "required": { 12 | "default_value": ( 13 | "STRING", 14 | { 15 | "default": "", 16 | "multiline": False, 17 | "tooltip": "LoRA name." 18 | } 19 | ), 20 | "id": ( 21 | "STRING", 22 | { 23 | "default": "Node ID", 24 | "multiline": False, 25 | "tooltip": "LoRA selection node identifier" 26 | } 27 | ), 28 | "label": ( 29 | "STRING", 30 | { 31 | "default": "Node Label", 32 | "multiline": False, 33 | "tooltip": "LoRA selection node's label" 34 | } 35 | ), 36 | "base_model": ( 37 | ["SD1.5", "SDXL", "CogVideoX", "Flux"], 38 | { 39 | "default": "SD1.5", 40 | "tooltip": "Which base model is this LoRA meant for?" 41 | } 42 | ), 43 | } 44 | } 45 | 46 | @classmethod 47 | def IS_CHANGED(s, image): 48 | # always update 49 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 50 | return m.digest().hex() 51 | 52 | RETURN_TYPES = ("STRING",) 53 | RETURN_NAMES = ("lora_name",) 54 | FUNCTION = "parse_lora" 55 | OUTPUT_NODE = {False} 56 | CATEGORY = "Playbook 3D" 57 | 58 | def parse_lora(self, default_value, id, label, base_model): 59 | return (default_value,) 60 | 61 | NODE_CLASS_MAPPINGS = { 62 | "Playbook LoRA Selection": PlaybookLoRASelection 63 | } 64 | 65 | NODE_DISPLAY_NAME_MAPPINGS = { 66 | "Playbook LoRA Selection": "Playbook LoRA Selection (Dynamic Input)" 67 | } 68 | -------------------------------------------------------------------------------- /playbookNumber.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import hashlib 3 | import time 4 | 5 | class PlaybookNumber: 6 | def __init__(self): 7 | pass 8 | 9 | @classmethod 10 | def INPUT_TYPES(s): 11 | return { 12 | "required": { 13 | "id": ("STRING", {"multiline": False, "default": "Node ID"} ), 14 | "label": ("STRING", {"multiline": False, "default": "Node Label"} ), 15 | "min": ("INT", {"multiline": False, "default": 0, "display": "number"} ), 16 | "max": ("INT", {"multiline": False, "default": 100, "display": "number"} ), 17 | }, 18 | "optional": { 19 | "default_value": ("INT", 20 | { 21 | "multiline": True, 22 | "display": "number", 23 | "min": -2147483647, 24 | "max": 2147483647, 25 | "default": 0 26 | }, 27 | ), 28 | } 29 | } 30 | 31 | @classmethod 32 | def IS_CHANGED(s, image): 33 | # always update 34 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 35 | return m.digest().hex() 36 | 37 | RETURN_TYPES = ("INT",) 38 | RETURN_NAMES = ("number",) 39 | 40 | FUNCTION = "parse_number" 41 | 42 | OUTPUT_NODE = { False } 43 | 44 | CATEGORY = "Playbook 3D" 45 | 46 | def parse_number(self, id, min, max, label, default_value = None): 47 | if not id or (isinstance(id, str) and not id.strip().isdigit()): 48 | return [int(np.clip(default_value, min, max))] 49 | id_int = int(id) 50 | return [int(np.clip(id_int, min, max))] 51 | 52 | 53 | NODE_CLASS_MAPPINGS = { "Playbook Number": PlaybookNumber } 54 | 55 | NODE_DISPLAY_NAME_MAPPINGS = { "Playbook Number": "Playbook Number (External)" } -------------------------------------------------------------------------------- /playbookSeed.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | import random 4 | 5 | 6 | class PlaybookSeed: 7 | def __init__(self): 8 | pass 9 | 10 | @classmethod 11 | def INPUT_TYPES(s): 12 | return { 13 | "required": { 14 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 15 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 16 | "default_value": ( 17 | "INT", 18 | { 19 | "multiline": False, 20 | "display": "number", 21 | "default": 0, 22 | "min": -999999999999999, 23 | "max": 999999999999999, 24 | }, 25 | ), 26 | "setting": (["Fixed", "Random"], {"default": "Fixed"}), 27 | }, 28 | } 29 | 30 | @classmethod 31 | def IS_CHANGED(s, image): 32 | # always update 33 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 34 | return m.digest().hex() 35 | 36 | RETURN_TYPES = ("INT",) 37 | RETURN_NAMES = ("seed",) 38 | 39 | FUNCTION = "get_seed" 40 | 41 | OUTPUT_NODE = {False} 42 | 43 | CATEGORY = "Playbook 3D" 44 | 45 | def get_seed(self, id, label, default_value, setting): 46 | """ 47 | Returns a seed depending on the chosen setting. 48 | If setting is "Fixed", returns the given default_value. 49 | If setting is "Random", returns a randomly generated seed. 50 | """ 51 | 52 | if setting == "Fixed": 53 | return [default_value] 54 | 55 | return [self.generate_random_seed()] 56 | 57 | def generate_random_seed(self, num_digits=15) -> int: 58 | """ 59 | Generate a random seed with num_digits number of digits. 60 | """ 61 | 62 | range_start = 10 ** (num_digits - 1) 63 | range_end = (10**num_digits) - 1 64 | return random.randint(range_start, range_end) 65 | 66 | 67 | NODE_CLASS_MAPPINGS = {"Playbook Seed": PlaybookSeed} 68 | NODE_DISPLAY_NAME_MAPPINGS = {"Playbook Seed": "Playbook Seed"} 69 | -------------------------------------------------------------------------------- /playbookText.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import time 3 | 4 | class PlaybookText: 5 | def __init__(self): 6 | pass 7 | 8 | @classmethod 9 | def INPUT_TYPES(s): 10 | return{ 11 | "required": { 12 | "id": ("STRING", { "multiline": False, "default": "Node ID"},), 13 | "label": ("STRING", { "multiline": False, "default": "Node Label"},), 14 | }, 15 | "optional": { 16 | "default_value": ("STRING", {"multiline": True},), 17 | "trigger_words": ("STRING", {"multiline": True}) 18 | } 19 | } 20 | 21 | @classmethod 22 | def IS_CHANGED(s, image): 23 | # always update 24 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 25 | return m.digest().hex() 26 | 27 | RETURN_TYPES = ("STRING", "STRING") 28 | RETURN_NAMES = ("text", "trigger_words") 29 | 30 | FUNCTION = "parse_text" 31 | 32 | OUTPUT_NODE = { False } 33 | 34 | CATEGORY = "Playbook 3D" 35 | 36 | def parse_text(self, id, label, default_value = None, trigger_words = None): 37 | return [default_value, trigger_words] 38 | 39 | NODE_CLASS_MAPPINGS = { "Playbook Text": PlaybookText} 40 | NODE_DISPLAY_NAME_MAPPINGS = { "Playbook Text": "Playbook Text (External)"} 41 | -------------------------------------------------------------------------------- /playbookVideo.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | import cv2 4 | import requests 5 | import tempfile 6 | import os 7 | import hashlib 8 | import time 9 | 10 | class PlaybookVideo: 11 | def __init__(self): 12 | pass 13 | 14 | @classmethod 15 | def INPUT_TYPES(s): 16 | return { 17 | "required": { 18 | "id": ("STRING", {"multiline": False, "default": "Node ID"}), 19 | "label": ("STRING", {"multiline": False, "default": "Node Label"}), 20 | "frame_load_cap": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}), 21 | "skip_first_frames": ("INT", {"default": 0, "min": 0, "max": 1000, "step": 1}), 22 | "select_every_nth": ("INT", {"default": 1, "min": 1, "max": 1000, "step": 1}), 23 | }, 24 | "optional": { 25 | "default_value": ("IMAGE",), 26 | "default_url": ("STRING", {"multiline": False, "default": ""}) 27 | }, 28 | } 29 | 30 | @classmethod 31 | def IS_CHANGED(s, image): 32 | # always update 33 | m = hashlib.sha256().update(str(time.time()).encode("utf-8")) 34 | return m.digest().hex() 35 | 36 | RETURN_TYPES = ("IMAGE",) 37 | RETURN_NAMES = ("images",) 38 | FUNCTION = "parse_video" 39 | OUTPUT_NODE = False 40 | CATEGORY = "Playbook 3D" 41 | 42 | def parse_video(self, id, label, default_url="", frame_load_cap=0, skip_first_frames=0, select_every_nth=1, default_value=None): 43 | try: 44 | frames = [] 45 | if default_url and default_url.startswith('http'): 46 | print(f"Debug: Downloading video from {default_url}") 47 | video_request = requests.get(default_url, stream=True) 48 | if video_request.status_code != 200: 49 | print(f"Debug: Failed to download video, status code: {video_request.status_code}") 50 | if default_value is not None: 51 | print("Debug: Using default value") 52 | return (default_value,) 53 | return (None,) 54 | 55 | # Download and process video 56 | with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as temp_video_file: 57 | for chunk in video_request.iter_content(chunk_size=8192): 58 | if chunk: 59 | temp_video_file.write(chunk) 60 | temp_video_file_path = temp_video_file.name 61 | 62 | try: 63 | cap = cv2.VideoCapture(temp_video_file_path) 64 | frame_counter = 0 65 | frames_loaded = 0 66 | print("Debug: Starting frame extraction") 67 | 68 | while cap.isOpened(): 69 | ret, frame = cap.read() 70 | if not ret: 71 | break 72 | 73 | frame_counter += 1 74 | if frame_counter <= skip_first_frames: 75 | continue 76 | if (frame_counter - skip_first_frames - 1) % select_every_nth != 0: 77 | continue 78 | 79 | # Convert BGR to RGB 80 | frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 81 | # Convert to float32 and normalize to 0-1 range 82 | frame = frame.astype(np.float32) / 255.0 83 | # Convert to tensor and keep in [H, W, C] format 84 | frame_tensor = torch.from_numpy(frame) 85 | frames.append(frame_tensor) 86 | 87 | frames_loaded += 1 88 | if frame_load_cap > 0 and frames_loaded >= frame_load_cap: 89 | break 90 | 91 | cap.release() 92 | finally: 93 | try: 94 | os.unlink(temp_video_file_path) 95 | except: 96 | pass 97 | 98 | if frames: 99 | print(f"Debug: Processed {len(frames)} frames") 100 | frames_tensor = torch.stack(frames) # [N, H, W, C] 101 | print(f"Debug: Final tensor shape: {frames_tensor.shape}") 102 | print(f"Debug: Final tensor dtype: {frames_tensor.dtype}") 103 | return (frames_tensor,) 104 | 105 | # If no valid default_url or no frames extracted 106 | print("Debug: No valid default_url or no frames extracted") 107 | if default_value is not None: 108 | print("Debug: Using default value") 109 | return (default_value,) 110 | return (None,) 111 | 112 | except Exception as e: 113 | print(f"Debug: Exception occurred: {str(e)}") 114 | if default_value is not None: 115 | print("Debug: Using default value after exception") 116 | return (default_value,) 117 | return (None,) 118 | 119 | NODE_CLASS_MAPPINGS = { 120 | "Playbook Video": PlaybookVideo 121 | } 122 | 123 | NODE_DISPLAY_NAME_MAPPINGS = { 124 | "Playbook Video": "Playbook Video (External)" 125 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "playbook-nodes" 3 | description = "Custom nodes for connecting 3D scenes and ComfyUI workflows." 4 | version = "1.0.0" 5 | license = {file = "LICENSE"} 6 | 7 | [project.urls] 8 | Repository = "https://github.com/playbook3d/playbook-nodes" 9 | # Used by Comfy Registry https://comfyregistry.org 10 | 11 | [tool.comfy] 12 | PublisherId = "playbook" 13 | DisplayName = "playbook-nodes" 14 | Icon = "" 15 | -------------------------------------------------------------------------------- /renderResult.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import numpy as np 3 | from PIL import Image 4 | from io import BytesIO 5 | from base64 import b64encode 6 | 7 | class UploadRenderResult: 8 | def __init__(self): 9 | pass 10 | 11 | @classmethod 12 | def INPUT_TYPES(s): 13 | return { 14 | "required": { 15 | "images": ("IMAGE",), 16 | "api_key": ("STRING", { "multiline": False }) 17 | }, 18 | } 19 | 20 | RETURN_TYPES = ("STRING",) 21 | RETURN_NAMES = ("URL",) 22 | 23 | FUNCTION = "parse_result" 24 | 25 | OUTPUT_NODE = { True } 26 | 27 | CATEGORY = "Playbook 3D" 28 | 29 | def parse_result(self, api_key, images): 30 | i = 255. * images.cpu().numpy() 31 | i = np.squeeze(i) 32 | img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8), 'RGB') 33 | buffer = BytesIO() 34 | img.save(buffer, "PNG") 35 | buffer.seek(0) 36 | img_data = buffer.getvalue() 37 | 38 | base_url = "https://accounts.playbook3d.com" 39 | user_token = None 40 | jwt_request = requests.get(f"{base_url}/token-wrapper/get-tokens/{api_key}") 41 | 42 | try: 43 | if jwt_request is not None: 44 | 45 | user_token = jwt_request.json()["access_token"] 46 | except Exception as e: 47 | print(f"Error with node: {e}") 48 | raise ValueError("API Key not found/Incorrect") 49 | 50 | try: 51 | headers = {"Authorization": f"Bearer {user_token}"} 52 | result_request = requests.get(f"{base_url}/upload-assets/get-upload-urls", headers=headers) 53 | if result_request.status_code == 200: 54 | result_url = result_request.json()["save_result"] 55 | result_response = requests.put(url=result_url, data=img_data) 56 | if result_response.status_code == 200: 57 | download_request = requests.get(f"{base_url}/upload-assets/get-download-urls", headers=headers) 58 | download_url = download_request.json()["save_result"] 59 | return [download_url] 60 | except Exception: 61 | raise ValueError("Error with uploading Result") 62 | raise ValueError("Error with uploading Result") 63 | 64 | 65 | NODE_CLASS_MAPPINGS = { 66 | "Playbook Render Result": UploadRenderResult 67 | } 68 | 69 | NODE_DISPLAY_NAME_MAPPINGS = { 70 | "Playbook Render Result": "Playbook Render Result" 71 | } 72 | -------------------------------------------------------------------------------- /template.py: -------------------------------------------------------------------------------- 1 | class PlaybookNode: 2 | def __init__(self): 3 | pass 4 | 5 | @classmethod 6 | def INPUT_TYPES(s): 7 | return { 8 | "required": { 9 | 10 | } 11 | } 12 | 13 | RETURN_TYPES = {} 14 | RETURN_NAMES = {} 15 | 16 | FUNCTION = {} 17 | 18 | OUTPUT_NODE = {} 19 | 20 | CATEGORY = {"Playbook/"} 21 | 22 | def node_function(self): 23 | # Functions that the node would need 24 | print("node is running") 25 | 26 | 27 | NODE_CLASS_MAPPINGS = {} 28 | 29 | NODE_DISPLAY_NAME_MAPPINGS = {} --------------------------------------------------------------------------------