diff --git a/README.md b/README.md index 18f7051..0b0d420 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,88 @@ -# ComfyUI-Benripack +# ComfyUI Benripack Nodes -## Description +This package contains three custom nodes for ComfyUI: -ComfyUI-Benripack is an extension for ComfyUI that provides a CharacterPipe node. This node allows for managing various elements such as images, prompts, and models in a single structure, simplifying the workflow for character-based image generation. +1. Benri Character Pipe +2. Benri Load 3D Model +3. Benri Animation Extractor -## Features +## 1. Benri Character Pipe -- Management of face and pose images -- Handling of positive and negative master prompts -- Handling of positive and negative character prompts -- Integration of model, CLIP, VAE, and IPAdapter +This node manages character information and prompts for image generation. + +### Parameters: + +- **character_pipe** (optional): An existing CHARACTER_PIPE object +- **face_image** (optional): A face image (IMAGE) +- **pose_image** (optional): A pose image (IMAGE) +- **add_positive_master_prompt** (optional): Positive master prompt (CONDITIONING) +- **add_negative_master_prompt** (optional): Negative master prompt (CONDITIONING) +- **add_positive_char_prompt** (optional): Positive character prompt (CONDITIONING) +- **add_negative_char_prompt** (optional): Negative character prompt (CONDITIONING) +- **model** (optional): An AI model (MODEL) +- **clip** (optional): A CLIP model (CLIP) +- **vae** (optional): A VAE model (VAE) +- **ipadapter** (optional): An IPAdapter model (IPADAPTER) + +### Functionality: + +The node combines all inputs into a CharacterPipe object that can be used for further processing in the image generation pipeline. + +## 2. Benri Load 3D Model + +This node loads a 3D model (.fbx) from the 3d_models directory in the root of your ComfyUI installation. Before using this node create the +directory 3d_models. Than put in some .fbx files. + +### Parameters: + +- **model_name** (required): Name of the 3D model to load + +### Functionality: + +The node loads the selected 3D model. + +## 3. Benri Animation Extractor + +This node extracts animation frames from a 3D model. If you select 4 rows and 4 columns the node will take a snapshot every 1/16 of the +animation time. You can set the orientation of the model. You can also rotate the model. The created tile-image can be used to create consistent +animations of the given object. + +### Parameters: + +- **cols** (required): Number of columns in the output image (INT, default: 4) +- **rows** (required): Number of rows in the output image (INT, default: 4) +- **orientation** (optional): Orientation of the model ("Front", "Back", "Left", "Right", "Rotate") +- **model** (optional): A 3D model (MODEL_3D) + +### Functionality: + +The node generates an image with multiple frames of an animation based on the given 3D model and specified parameters. + +## Example GIFs + +Here are some example GIFs demonstrating the functionality of these nodes: + +![Example 1](img/workflow.png) + +![Example 2](img/walk.gif) + +![Example 3](img/jump.gif) ## Installation -1. Clone this repository into your ComfyUI extensions folder: -2. Restart ComfyUI to load the extension. +1. Copy the node files into your ComfyUI custom_nodes directory. +2. Ensure you have a `3d_models` folder in your ComfyUI main directory. +3. Place your 3D models (.fbx) in the `3d_models` folder. ## Usage -The CharacterPipe node can be used in your ComfyUI workflow. It accepts various inputs and returns a consolidated CharacterPipe structure as well as individual components. +After installation, you can use the nodes in your ComfyUI workflow. Connect the nodes according to your requirements and experiment with different settings to achieve desired results. -### Inputs +## Dependencies -- `character_pipe`: An existing CharacterPipe structure (optional) -- `face_image`: A face image -- `pose_image`: A pose image -- `add_positive_master_prompt`: Positive master prompt to add -- `add_negative_master_prompt`: Negative master prompt to add -- `add_positive_char_prompt`: Positive character prompt to add -- `add_negative_char_prompt`: Negative character prompt to add -- `model`: A model -- `clip`: A CLIP model -- `vae`: A VAE model -- `ipadapter`: An IPAdapter model +This package relies on the following Python libraries: +- torch +- numpy +- PIL (Python Imaging Library) -### Outputs - -- `character_pipe`: The updated CharacterPipe structure -- Individual components (images, prompts, models) - -## License - -MIT \ No newline at end of file +Make sure these dependencies are installed in your ComfyUI environment. diff --git a/__init__.py b/__init__.py index 9d84aa6..227b7b8 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1,50 @@ -from .nodes import * +import os +import shutil +import sys +import os +import filecmp +import shutil + +import __main__ + +from distutils.dir_util import copy_tree +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +WEB_DIRECTORY = "./web" + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY'] +# Get the current script's directory +current_dir = os.path.dirname(os.path.abspath(__file__)) + +# Define paths +folder_web = os.path.join(current_dir, "web") +extensions_folder = os.path.join(current_dir,"ComfyUI", "custom_nodes", 'ComfyUI_BenriPack','web') +folder_3d_models = os.path.join(current_dir, "ComfyUI", "3d_models") +destination_3d_models = os.path.join(extensions_folder, "3d_models") + +def cleanup(): + if os.path.exists(extensions_folder): + try: + shutil.rmtree(extensions_folder) + print('\033[92mBenriPack: Removed old extension folder\033[0m') + except Exception as e: + print(f'\033[91mBenriPack: Error removing old extension folder: {e}\033[0m') + +def copy_3d_models(): + try: + if not os.path.exists(folder_3d_models): + print(f'\033[93mBenriPack: 3d_models folder not found at {folder_3d_models}\033[0m') + return + + if not os.path.exists(extensions_folder): + os.makedirs(extensions_folder) + + + shutil.copytree(folder_3d_models, destination_3d_models, dirs_exist_ok=True) + print('\033[92mBenriPack: Copied 3d_models to extension folder\033[0m') + except Exception as e: + print(f'\033[91mBenriPack: Error copying 3d_models: {e}\033[0m') + +if __name__ == "__main__": + cleanup() + copy_3d_models() \ No newline at end of file diff --git a/example/example_workflow.json b/example/example_workflow.json new file mode 100644 index 0000000..16a5024 --- /dev/null +++ b/example/example_workflow.json @@ -0,0 +1,161 @@ +{ + "last_node_id": 76, + "last_link_id": 181, + "nodes": [ + { + "id": 74, + "type": "Load3DModel", + "pos": { + "0": 538.924560546875, + "1": 886.9126586914062, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0 + }, + "size": { + "0": 315, + "1": 58 + }, + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "3d model", + "type": "MODEL_3D", + "links": [ + 180 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Load3DModel" + }, + "widgets_values": [ + "Walk.fbx" + ] + }, + { + "id": 75, + "type": "AnimationExtractor", + "pos": { + "0": 998, + "1": 768, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0 + }, + "size": [ + 464.94115517539467, + 713.950513559133 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL_3D", + "link": 180 + } + ], + "outputs": [ + { + "name": "animation", + "type": "IMAGE", + "links": [ + 181 + ], + "shape": 3, + "slot_index": 0 + } + ], + "properties": {}, + "widgets_values": [ + 4, + 4, + "Right", + null + ] + }, + { + "id": 76, + "type": "SaveImage", + "pos": { + "0": 1677.9161376953125, + "1": 874.073486328125, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0 + }, + "size": [ + 369.8974890511033, + 607.9224278894808 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 181 + } + ], + "outputs": [], + "properties": {}, + "widgets_values": [ + "ComfyUI" + ] + } + ], + "links": [ + [ + 180, + 74, + 0, + 75, + 0, + "MODEL_3D" + ], + [ + 181, + 75, + 0, + 76, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.740024994425886, + "offset": [ + -152.98264049451026, + -637.7301452204401 + ] + }, + "groupNodes": {} + }, + "version": 0.4 +} \ No newline at end of file diff --git a/img/jump.gif b/img/jump.gif new file mode 100644 index 0000000..d749555 Binary files /dev/null and b/img/jump.gif differ diff --git a/img/walk.gif b/img/walk.gif new file mode 100644 index 0000000..3517eab Binary files /dev/null and b/img/walk.gif differ diff --git a/img/workflow.png b/img/workflow.png new file mode 100644 index 0000000..1ea4f54 Binary files /dev/null and b/img/workflow.png differ diff --git a/nodes.py b/nodes.py index ee9eb8a..9e0ca1f 100644 --- a/nodes.py +++ b/nodes.py @@ -1,6 +1,23 @@ import torch import numpy as np import comfy.model_management as model_management +import folder_paths +from PIL import Image, ImageOps +import os +from server import PromptServer +import shutil +import __main__ + +class Model3D: + def __init__(self, model_path): + self.model_path = model_path + + def to_dict(self): + return { + "class_type": "Model3D", + "model_path": self.model_path + } + class CharacterPipe: def __init__(self, face_image=None, pose_image=None, positive_master_prompt=None, negative_master_prompt=None, positive_char_prompt=None, negative_char_prompt=None, model=None, clip=None, vae=None, ipadapter=None): @@ -122,11 +139,98 @@ class CharacterPipeNode: out.append(n) return out + + +class AnimationExtractor: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "cols": ("INT", {"default": 4, "min": 1, "max": 20, "step": 1}), + "rows": ("INT", {"default": 4, "min": 1, "max": 20, "step": 1}), + }, + "optional": { + "orientation": (["Front", "Back", "Left", "Right", "Rotate"],), + "model": ("MODEL_3D",), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("animation",) + + @classmethod + def IS_CHANGED(s): + # This method tells ComfyUI to always consider the node as changed for certain inputs + return float("NaN") + + FUNCTION = "extract_animation" + + + def extract_animation(self, cols=4, rows=4, orientation="Front", model=None, unique_id=None): + temp_dir = folder_paths.get_temp_directory() + + image_path = os.path.join(temp_dir, "animation_"+unique_id+".png") + i = Image.open(image_path) + poseImage = i.convert("RGB") + poseImage = np.array(poseImage).astype(np.float32) / 255.0 + poseImage = torch.from_numpy(poseImage)[None,] + return (poseImage, poseImage) + + + +folder_paths.add_model_folder_path("3d_models","./ComfyUI/3d_models") + +def copy_files(): + files = folder_paths.get_filename_list("3d_models") + if not os.path.exists("./ComfyUI/custom_nodes/ComfyUI-Benripack/web/3d_models"): + os.makedirs("./ComfyUI/custom_nodes/ComfyUI-Benripack/web/3d_models") + + if len(files) > 0: + for file in files: + shutil.copy( os.path.join("./ComfyUI/3d_models/", file), "./ComfyUI/custom_nodes/ComfyUI-Benripack/web/3d_models") + + +class Load3DModel: + @classmethod + def INPUT_TYPES(s): + copy_files() + return { + "required": { + "model_name": (folder_paths.get_filename_list("3d_models"), ), + }, + "optional": { + } + } + + RETURN_TYPES = ("MODEL_3D",) + RETURN_NAMES = ("3d model",) + FUNCTION = "load_model" + + def load_model(self, model_name): + model_path = folder_paths.get_full_path("3d_models", model_name) + model = Model3D(model_path) + + return (model,) + + @classmethod + def IS_CHANGED(s, model_name): + copy_files() + # This method tells ComfyUI to always consider the node as changed for certain inputs + return float("NaN") NODE_CLASS_MAPPINGS = { - "CharacterPipe": CharacterPipeNode + "CharacterPipe": CharacterPipeNode, + "Load3DModel": Load3DModel, + "AnimationExtractor": AnimationExtractor } NODE_DISPLAY_NAME_MAPPINGS = { - "CharacterPipe": "Character Pipe" -} \ No newline at end of file + "CharacterPipe": "Benri Character Pipe", + "Load3DModel": "Benri Load 3D Model", + "AnimationExtractor": "Benri Animation Extractor" +} + +WEB_DIRECTORY = "./web" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..10513f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +torch +numpy +Pillow \ No newline at end of file diff --git a/web/editor.html b/web/editor.html new file mode 100644 index 0000000..d8640a7 --- /dev/null +++ b/web/editor.html @@ -0,0 +1,13 @@ + + + + FBX Viewer + + + + + +
+ + + \ No newline at end of file diff --git a/web/editor.js b/web/editor.js new file mode 100644 index 0000000..7c01dbe --- /dev/null +++ b/web/editor.js @@ -0,0 +1,256 @@ +class Editor { + constructor({ orientation, model, rows, cols,id }) { + this.id = id; + this.orientation = orientation; + this.model = model; + this.rows = rows; + this.cols = cols; + this.isInitialCapturing = false; + this.capturedFrames = 0; + this.frames = []; + this.isCapturing = false; + this.captureCount = 0; + + this.scene = null; + this.camera = null; + this.renderer = null; + this.mixer = null; + this.clock = null; + this.gModel = null; + } + + async init() { + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.5, 1000); + this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer.domElement.id = 'editor'+this.id; + this.renderer.setClearColor(0x0000ff); + this.renderer.setSize(window.innerWidth, window.innerHeight); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + document.getElementById('container').appendChild(this.renderer.domElement); + + const ambientLight = new THREE.AmbientLight(0xeeeeee, 0.5); + this.scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); + directionalLight.position.set(5, 30, 57.5); + directionalLight.castShadow = true; + directionalLight.shadow.mapSize.width = 2048; + directionalLight.shadow.mapSize.height = 2048; + this.scene.add(directionalLight); + + this.clock = new THREE.Clock(); + this.gModel = await this.loadModel().catch(console.error); + if (this.gModel) this.captureInitialFrames(); + } + + async update({ orientation, model, rows, cols }) { + if (orientation && orientation !== this.orientation) { + this.orientation = orientation; + this.updateOrientation(this.gModel); + this.captureInitialFrames(); + } + + if (model && model !== this.model) { + this.model = model; + this.gModel = await this.loadModel(); + this.captureInitialFrames(); + } + + if (rows && rows !== this.rows) { + this.rows = rows; + this.captureInitialFrames(); + } + + if (cols && cols !== this.cols) { + this.cols = cols; + this.captureInitialFrames(); + } + } + + loadModel() { + return new Promise((resolve, reject) => { + const loader = new THREE.FBXLoader(); + if (!this.model) { + reject('No model selected'); + return; + } + + loader.load('/extensions/ComfyUI-Benripack/3d_models/'+this.model, (object) => { + let box = new THREE.Box3().setFromObject(object); + let size = box.getSize(new THREE.Vector3()); + const maxAxis = Math.max(size.x, size.y, size.z); + object.scale.multiplyScalar(100.0 / maxAxis); + + object.traverse((child) => { + if (child.isMesh && !child.name.includes('Joints')) { + child.castShadow = true; + child.receiveShadow = true; + // set color + //const material = new THREE.MeshPhongMaterial({ color: 0xaaaaaa }); + //child.material.color = material.color; + } + }); + + if (this.gModel) this.scene.remove(this.gModel); + this.updateOrientation(object); + this.scene.add(object); + box.getCenter(object.position).multiplyScalar(-1); + object.position.y = 0; + + if (object.animations.length > 0) { + this.mixer = new THREE.AnimationMixer(object); + const action = this.mixer.clipAction(object.animations[0]); + action.play(); + } + box = new THREE.Box3().setFromObject(object); + size = box.getSize(new THREE.Vector3()); + this.camera.position.set(0, size.y/2 + 20, 120); + this.camera.lookAt(new THREE.Vector3(0, size.y/2, 0)); + resolve(object); + }); + }); + } + + updateOrientation(object) { + switch (this.orientation) { + case 'Front': object.rotation.y = 0; break; + case 'Rotate': object.rotation.y = 0; break; + case 'Back': object.rotation.y = Math.PI; break; + case 'Left': object.rotation.y = -Math.PI / 2; break; + case 'Right': object.rotation.y = Math.PI / 2; break; + } + } + + captureInitialFrames() { + this.isInitialCapturing = true; + this.capturedFrames = 0; + this.frames = []; + const captureNextFrame = () => { + if (this.capturedFrames < this.rows * this.cols) { + if (this.mixer) { + var t = this.mixer._actions[0]._clip.duration; + this.mixer.setTime(t * (this.capturedFrames / (this.rows * this.cols))); + } + if(this.orientation == "Rotate") this.gModel.rotation.y = 2 * Math.PI * (this.capturedFrames / (this.rows * this.cols)); + this.renderer.render(this.scene, this.camera); + this.captureFrame(); + this.capturedFrames++; + requestAnimationFrame(captureNextFrame); + } else { + this.isInitialCapturing = false; + if (this.mixer) this.mixer.setTime(0); + requestAnimationFrame(this.animate.bind(this)); + } + }; + setTimeout(() => { + captureNextFrame(); + }, 2000); + } + + async captureFrame() { + this.renderer.render(this.scene, this.camera); + this.frames.push(this.renderer.domElement.toDataURL()); + this.captureCount++; + if (this.captureCount >= (this.rows * this.cols)) { + this.isCapturing = false; + console.log('Captured frames', this.frames); + var animation = await this.createAnimationImage(this.rows, this.cols, this.frames); + window.parent.postMessage({ type: 'capture', frames: this.frames, animation: animation, id: this.id }, '*'); + } + } + + animate() { + requestAnimationFrame(this.animate.bind(this)); + if (!this.isInitialCapturing) { + if(this.mixer) this.mixer.update(this.clock.getDelta()); + if(this.orientation == "Rotate"){ + this.gModel.rotation.y += 0.01 + this.gModel.rotation.y %= 2 * Math.PI + } + } + this.renderer.render(this.scene, this.camera); + } + + startCapture() { + this.frames = []; + this.isCapturing = true; + this.captureCount = 0; + } + + createAnimationImage(rows, cols, frames) { + + return new Promise((resolve, reject) => { + if (frames.length === 0) { + reject('No frames to create animation image'); + return; + } + + + // Create a temporary image to get dimensions + const tempImg = new Image(); + tempImg.onload = () => { + + const frameWidth = tempImg.width; + const frameHeight = tempImg.height; + + + // Create a canvas that will hold all frames in a grid + this.canvas = document.createElement('canvas'); + this.canvas.width = frameWidth * cols; + this.canvas.height = frameHeight * rows; + + const ctx = this.canvas.getContext('2d'); + + // Function to draw each frame + const drawFrame = (index) => { + if (index >= frames.length || index >= rows * cols) { + // All frames have been drawn or grid is full, resolve with the result + console.log("Länge: " + this.canvas.toDataURL().length,this.id) + resolve(this.canvas.toDataURL()); + return; + } + + const img = new Image(); + img.onload = () => { + const col = index % cols; + const row = Math.floor(index / cols); + ctx.drawImage(img, col * frameWidth, row * frameHeight, frameWidth, frameHeight); + drawFrame(index + 1); + }; + img.onerror = reject; + img.src = frames[index]; + }; + + // Start drawing frames + drawFrame(0); + }; + tempImg.onerror = reject; + tempImg.src = frames[0]; + }); + } + + onWindowResize() { + this.camera.aspect = window.innerWidth / window.innerHeight; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(window.innerWidth, window.innerHeight); + } + } + + // Event listeners + function onMessage(event) { + if(!window.editor) window.editor = {}; + if (!event.data.orientation) return; + if (window.editor && window.editor[event.data.id]) { + window.editor[event.data.id].update(event.data); + } else { + window.editor[event.data.id] = new Editor(event.data); + window.editor[event.data.id].init(); + } + } + + window.addEventListener('resize', () => { + for (const key in window.editor) window.editor[key].onWindowResize(); + }) + window.addEventListener('message', onMessage); \ No newline at end of file diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..7cf87c9 --- /dev/null +++ b/web/main.js @@ -0,0 +1,180 @@ +import { app } from "/scripts/app.js" + +class AnimationExtractor{ + orientation; + model; + container; + frames = []; + animation; + constructor(data) { + this.orientation = data.orientation; + this.model = data.model; + this.container = data.container; + this.iframe = this.container.children[0]; + this.id = data.id; + window.addEventListener('message', this.onMessage.bind(this)); + } + onMessage(event) { + if(event.data.type === "capture" && event.data.id === this.id) { + this.frames = event.data.frames; + this.animation = event.data.animation; + this.uploadData(); + } + } + update(data) { + this.orientation = data.orientation; + this.model = data.model; + data.id = this.id; + this.iframe?.contentWindow?.postMessage(data, "*"); + } + + async uploadData() { + for(let i = 0; i < this.frames.length; i++){ + const blobData = await fetch(this.frames[i]).then(r => r.blob()) + const filename = `frame_${i}.png` + + let formData = new FormData() + formData.append('image', blobData, filename) + formData.append('overwrite', 'true') + formData.append('type', 'temp') + formData.append('subFolder', 'AnimationExtractor') + const resp = await fetch('/upload/image', { + method: 'POST', + body: formData, + }) + if (resp.status === 200) { + const data = await resp.json() + + console.log("[AnimationExtractor] Upload image success.", data.name) + } + } + if(this.animation) { + const blobData = await fetch(this.animation).then(r => r.blob()) + const filename = `animation_${this.id}.png` + + let formData = new FormData() + formData.append('image', blobData, filename) + formData.append('overwrite', 'true') + formData.append('type', 'temp') + formData.append('subFolder', 'AnimationExtractor') + const resp = await fetch('/upload/image', { + method: 'POST', + body: formData, + }) + if (resp.status === 200) { + const data = await resp.json() + + console.log("[AnimationExtractor] Upload animation success.", data.name) + } + } + } +} + +function createSimpleHtmlLoader(node, inputName, app) { + node.name = inputName; + + const container = document.createElement('div'); + container.id = `comfyui-${inputName.toLowerCase()}`; + container.style.position = 'absolute'; + container.style.width = '100%'; + container.style.height = '100%'; + container.style.overflow = 'none'; + const iframe = document.createElement('iframe'); + iframe.src = '/extensions/ComfyUI-Benripack/editor.html'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + + container.appendChild(iframe); + document.body.appendChild(container); + + node.onRemoved = () => { + container.remove(); + }; + + function findModelName(linkId) { + if (!linkId) return "" + var graph = app.graph; + var originalNodeId = app.graph.links.find(x => x?.id == linkId).origin_id + var modelName = app.graph.getNodeById(originalNodeId).widgets[0].value + return modelName + } + + var anim = new AnimationExtractor({ + id: node.id, + orientation: node.widgets.find(x => x.name === "orientation").value, + model: findModelName(node.inputs.find(x => x.name === "model").link), + rows: node.widgets.find(x => x.name === "rows").value, + cols: node.widgets.find(x => x.name === "cols").value, + container: container + }) + let widget = { + type: "AnimationExtractor", + name: `AnimationExtractor${inputName}`, + AnimationExtractor:anim, + draw: function (ctx, _, widgetWidth, y, widgetHeight) { + const margin = 20 + const top_offset = 20 + const visible = app.canvas.ds.scale > 0.6 && this.type === "AnimationExtractor" + const w = _.size[0] - margin * 4 + const h = _.size[1] - margin * 4 + const clientRectBound = ctx.canvas.getBoundingClientRect() + const transform = new DOMMatrix() + .scaleSelf( + clientRectBound.width / ctx.canvas.width, + clientRectBound.height / ctx.canvas.height + ) + .multiplySelf(ctx.getTransform()) + .translateSelf(margin, margin + y) + + Object.assign(this.AnimationExtractor.container.style, { + left: `${transform.a * margin + transform.e}px`, + top: `${transform.d + transform.f + transform.d *margin}px`, + width: `${(w * transform.a) }px`, + height: `${((h * transform.d) + (- widgetHeight-100)*transform.d)}px`, + position: "absolute", + overflow: "hidden", + zIndex: app.graph._nodes.indexOf(node), + }) + + Object.assign(this.AnimationExtractor.container.children[0].style, { + transformOrigin: "50% 50%", + width: '100%', + height: '100%', + border: '0 none', + }) + + this.AnimationExtractor.container.hidden = !visible + + + this.AnimationExtractor.update({ + orientation: node.widgets.find(x => x.name === "orientation").value, + model: findModelName(node.inputs.find(x => x.name === "model").link), + rows: node.widgets.find(x => x.name === "rows").value, + cols: node.widgets.find(x => x.name === "cols").value, + }) + }, + parent: node + } + node.addCustomWidget(widget) + return { + widget: widget + }; +} + +app.registerExtension({ + name: "AnimationExtractor", + async init(app) { + console.log("AnimationExtractor initialized"); + }, + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "AnimationExtractor") { + nodeType.prototype.onNodeCreated = function() { + setTimeout(() => { + createSimpleHtmlLoader(this, "TestNode", app); + }, 1000); + + }; + } + } +}); \ No newline at end of file